Python: [Feature Branch] Merge from main to Azure AI branch (#2111)

* Do not build DevUI assets during .NET project build (#2010)

* .NET: Add unit tests for declarative executor SetMultipleVariables (#2016)

* Add unit tests for create conversation executor

* Update indentation and comment typo.

* Added unit tests for declarative executor SetMultipleVariablesExecutor

* Updated comments and syntactic sugar

* Python: DevUI: Use metadata.entity_id instead of model field (#1984)

* DevUI: Use metadata.entity_id for agent/workflow name instead of model field

* OpenAI Responses: add explicit request validation

* Review feedback

* .NET: DevUI - Do not automatically add/map OpenAI services/endpoints (#2014)

* Don't add OpenAIResponses as part of Dev UI

You should be able to add and remove Dev UI without impacting your other production endpoints.

* Remove `AddDevUI()` and do not map OpenAI endpoints from `MapDevUI()`

* Fix comment wording

* Revise documentation

---------

Co-authored-by: Daniel Roth <daroth@microsoft.com>

* Python: DevUI: Add OpenAI Responses API proxy support  + HIL for Workflows (#1737)

* DevUI: Add OpenAI Responses API proxy support with enhanced UI features

This commit adds support for proxying requests to OpenAI's Responses API,
allowing DevUI to route conversations to OpenAI models when configured to enable testing.

Backend changes:
- Add OpenAI proxy executor with conversation routing logic
- Enhance event mapper to support OpenAI Responses API format
- Extend server endpoints to handle OpenAI proxy mode
- Update models with OpenAI-specific response types
- Remove emojis from logging and CLI output for cleaner text

Frontend changes:
- Add settings modal with OpenAI proxy configuration UI
- Enhance agent and workflow views with improved state management
- Add new UI components (separator, switch) for settings
- Update debug panel with better event filtering
- Improve message renderers for OpenAI content types
- Update types and API client for OpenAI integration

* update ui, settings modal and workflow input form, add register cleanup hooks.

* add workflow HIL support, user mode, other fixes

* feat(devui): add human-in-the-loop (HIL) support with dynamic response schemas

Implement  HIL workflow support allowing workflows to pause for user input
with dynamically generated JSON schemas based on response handler type hints.

Key Features:
- Automatic response schema extraction from @response_handler decorators
- Dynamic form generation in UI based on Pydantic/dataclass response types
- Checkpoint-based conversation storage for HIL requests/responses
- Resume workflow execution after user provides HIL response

Backend Changes:
- Add extract_response_type_from_executor() to introspect response handlers
- Enrich RequestInfoEvent with response_schema via _enrich_request_info_event_with_response_schema()
- Map RequestInfoEvent to response.input.requested OpenAI event format
- Store HIL responses in conversation history and restore checkpoints

Frontend Changes:
- Add HILInputModal component with SchemaFormRenderer for dynamic forms
- Support Pydantic BaseModel and dataclass response types
- Render enum fields as dropdowns, strings as text/textarea, numbers, booleans, arrays, objects
- Display original request context alongside response form

Testing:
- Add  tests for checkpoint storage (test_checkpoints.py)
- Add schema generation tests for all input types (test_schema_generation.py)
- Validate end-to-end HIL flow with spam workflow sample

This enables workflows to seamlessly pause execution and request structured user input
with type-safe, validated forms generated automatically from response type annotations.

* improve HIL support, improve workflow execution view

* ui updates

* ui updates

* improve HIL for workflows, add auth and view modes

* update workflow

* security improvements , ui fixes

* fix mypy error

* update loading spinner in ui

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>

* .NET: Remove launchSettings.json from .gitignore in dotnet/samples (#2006)

* Remove launchSettings.json from .gitignore in dotnet/samples

* Update dotnet/samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/Properties/launchSettings.json

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

* Update dotnet/samples/AGUIClientServer/AGUIServer/Properties/launchSettings.json

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

---------

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

* DevUI: Serialize workflow input as string to maintain conformance with OpenAI Responses format (#2021)

Co-authored-by: Victor Dibia <chuvidi2003@gmail.com>

* Add Microsoft Agent Framework logo to assets (#2007)

* Updated package versions (#2027)

* DevUI: Prevent line breaks within words in the agent view (#2024)

Co-authored-by: Victor Dibia <chuvidi2003@gmail.com>

* .NET [AG-UI]: Adds support for shared state. (#1996)

* Product changes

* Tests

* Dojo project

* Cleanups

* Python: Fix underlying tool choice bug and all for return to previous Handoff subagent (#2037)

* Fix tool_choice override bug and add enable_return_to_previous support

* Add unit test for handoff checkpointing

* Handle tools when we have them

* added missing chatAgent params (#2044)

* .NET: fix ChatCompletions Tools serialization (#2043)

* fix serialization in chat completions on tools

* nit

* .NET: assign AgentCard's URL to mapped-endpoint if not defined explicitly (#2047)

* fix serialization in chat completions on tools

* nit

* write e2e test for agent card resolve + adjust behavior

* nit

* Version 1.0.0-preview.251110.1 (#2048)

* .NET: Remove moved OpenAPI sample and point to SK one. (#1997)

* Remove moved OpenAPI sample and point to SK one.

* Update dotnet/samples/GettingStarted/Agents/README.md

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

---------

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

* Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.4.2 to 4.0.4.6 (#2031)

---
updated-dependencies:
- dependency-name: AWSSDK.Extensions.Bedrock.MEAI
  dependency-version: 4.0.4.6
  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>

* .NET: Separate all memory and rag samples into their own folders (#2000)

* Separate all memory and rag samples into their own folders

* Fix broken link.

* Python: .Net: Dotnet devui compatibility fixes (#2026)

* DevUI: Add OpenAI Responses API proxy support with enhanced UI features

This commit adds support for proxying requests to OpenAI's Responses API,
allowing DevUI to route conversations to OpenAI models when configured to enable testing.

Backend changes:
- Add OpenAI proxy executor with conversation routing logic
- Enhance event mapper to support OpenAI Responses API format
- Extend server endpoints to handle OpenAI proxy mode
- Update models with OpenAI-specific response types
- Remove emojis from logging and CLI output for cleaner text

Frontend changes:
- Add settings modal with OpenAI proxy configuration UI
- Enhance agent and workflow views with improved state management
- Add new UI components (separator, switch) for settings
- Update debug panel with better event filtering
- Improve message renderers for OpenAI content types
- Update types and API client for OpenAI integration

* update ui, settings modal and workflow input form, add register cleanup hooks.

* add workflow HIL support, user mode, other fixes

* feat(devui): add human-in-the-loop (HIL) support with dynamic response schemas

Implement  HIL workflow support allowing workflows to pause for user input
with dynamically generated JSON schemas based on response handler type hints.

Key Features:
- Automatic response schema extraction from @response_handler decorators
- Dynamic form generation in UI based on Pydantic/dataclass response types
- Checkpoint-based conversation storage for HIL requests/responses
- Resume workflow execution after user provides HIL response

Backend Changes:
- Add extract_response_type_from_executor() to introspect response handlers
- Enrich RequestInfoEvent with response_schema via _enrich_request_info_event_with_response_schema()
- Map RequestInfoEvent to response.input.requested OpenAI event format
- Store HIL responses in conversation history and restore checkpoints

Frontend Changes:
- Add HILInputModal component with SchemaFormRenderer for dynamic forms
- Support Pydantic BaseModel and dataclass response types
- Render enum fields as dropdowns, strings as text/textarea, numbers, booleans, arrays, objects
- Display original request context alongside response form

Testing:
- Add  tests for checkpoint storage (test_checkpoints.py)
- Add schema generation tests for all input types (test_schema_generation.py)
- Validate end-to-end HIL flow with spam workflow sample

This enables workflows to seamlessly pause execution and request structured user input
with type-safe, validated forms generated automatically from response type annotations.

* improve HIL support, improve workflow execution view

* ui updates

* ui updates

* improve HIL for workflows, add auth and view modes

* update workflow

* security improvements , ui fixes

* fix mypy error

* update loading spinner in ui

* DevUI: Serialize workflow input as string to maintain conformance with OpenAI Responses format

* Phase 1: Add /meta endpoint and fix workflow event naming for .NET DevUI compatibility

* additional fixes for .NET DevUI workflow visualization item ID tracking

**Problem:**
.NET DevUI was generating different item IDs for ExecutorInvokedEvent and
ExecutorCompletedEvent, causing only the first executor to highlight in the
workflow graph. Long executor names and error messages also broke UI layout.

**Changes:**
- Add ExecutorActionItemResource to match Python DevUI implementation
- Track item IDs per executor using dictionary in AgentRunResponseUpdateExtensions
- Reuse same item ID across invoked/completed/failed events for proper pairing
- Add truncateText() utility to workflow-utils.ts
- Truncate executor names to 35 chars in execution timeline
- Truncate error messages to 150 chars in workflow graph nodes

** Details:**
- ExecutorActionItemResource registered with JSON source generation context
- Dictionary cleaned up after executor completion/failure to prevent memory leaks
- Frontend item tracking by unique item.id supports multiple executor runs
- All changes follow existing codebase patterns and conventions

Tested with review-workflow showing correct executor highlighting and state
transitions for sequential and concurrent executors.

* format fixes, remove cors tests

* remove unecessary attributes

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Reuben Bond <reuben.bond@gmail.com>

* DevUI: support having both an agent and a workflow with the same id in discovery (#2023)

* Python: Fix Model ID attribute not showing up in `invoke_agent` span (#2061)

* Best effort to surface the model id to invoke agent span

* Fix tests

* Fix tests

* Version 1.0.0-preview.251107.2 (#2065)

* Version 1.0.0-preview.251110.2 (#2067)

* Update README.md to change Grafana links to Azure portal links for dashboard access (#1983)

* .NET - Enable build & test on branch `feature-foundry-agents` (#2068)

* Tests good, mkay

* Update .github/workflows/dotnet-build-and-test.yml

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

* Enable feature build pipelines

---------

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

* Python: Add concrete AGUIChatClient (#2072)

* Add concrete AGUIChatClient

* Update logging docstrings and conventions

* PR feedback

* Updates to support client-side tool calls

* .NET: Move catalog samples to the HostedAgents folder (#2090)

* move catalog samples to the HostedAgents folder

* move the catalog samples' projects to the HostedAgents folder

* Bump OpenTelemetry.Instrumentation.Runtime from 1.12.0 to 1.13.0 (#1856)

---
updated-dependencies:
- dependency-name: OpenTelemetry.Instrumentation.Runtime
  dependency-version: 1.13.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>

* .NET: Bump Microsoft.SemanticKernel.Agents.Abstractions from 1.66.0 to 1.67.0 (#1962)

* Bump Microsoft.SemanticKernel.Agents.Abstractions from 1.66.0 to 1.67.0

---
updated-dependencies:
- dependency-name: Microsoft.SemanticKernel.Agents.Abstractions
  dependency-version: 1.67.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* .NET: Bump all Microsoft.SemanticKernel packages from 1.66.* to 1.67.* (#1969)

* Initial plan

* Update all Microsoft.SemanticKernel packages to 1.67.*

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Remove unrelated changes to package-lock.json and yarn.lock

Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>

---------

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: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@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: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>

* .NET: fix: WorkflowAsAgent Sample (#1787)

* fix: WorkflowAsAgent Sample

* Also makes ChatForwardingExecutor public

* feat: Expand ChatForwardingExecutor handled types

Make ChatForwardingExecutor match the input types of ChatProtocolExecutor.

* fix: Update for the new AgentRunResponseUpdate merge logic

AIAgent always sends out List<ChatMessage> now.

* Updated (#2076)

* Bump vite in /python/samples/demos/chatkit-integration/frontend (#1918)

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.9 to 7.1.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.1.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.12
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump Roslynator.Analyzers from 4.14.0 to 4.14.1 (#1857)

---
updated-dependencies:
- dependency-name: Roslynator.Analyzers
  dependency-version: 4.14.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 MishaKav/pytest-coverage-comment from 1.1.57 to 1.1.59 (#2034)

Bumps [MishaKav/pytest-coverage-comment](https://github.com/mishakav/pytest-coverage-comment) from 1.1.57 to 1.1.59.
- [Release notes](https://github.com/mishakav/pytest-coverage-comment/releases)
- [Changelog](https://github.com/MishaKav/pytest-coverage-comment/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mishakav/pytest-coverage-comment/compare/v1.1.57...v1.1.59)

---
updated-dependencies:
- dependency-name: MishaKav/pytest-coverage-comment
  dependency-version: 1.1.59
  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: Handle agent user input request in AgentExecutor (#2022)

* Handle agent user input request in AgentExecutor

* fix test

* Address comments

* Fix tests

* Fix tests

* Address comments

* Address comments

* Python: OpenAI Responses Image Generation Stream Support, Sample and Unit Tests (#1853)

* support for image gen streaming

* small fixes

* fixes

* added comment

* Python: Fix MCP Tool Parameter Descriptions Not Propagated to LLMs (#1978)

* mcp tool description fix

* small fix

* .NET: Allow extending agent run options via additional properties (#1872)

* Allow extending agent run options via additional properties

This mirrors the M.E.AI model in ChatOptions.AdditionalProperties which is very useful when building functionality pipelines.

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

* Expand XML documentation

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

* Add AdditionalProperties tests to AgentRunOptions

Co-authored-by: kzu <169707+kzu@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kzu <169707+kzu@users.noreply.github.com>

* Python: Use the last entry in the task history to avoid empty responses (#2101)

* Use the last entry in the task history to avoid empty responses

* History only contains Messages

* Updated package versions (#2104)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Reuben Bond <203839+ReubenBond@users.noreply.github.com>
Co-authored-by: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com>
Co-authored-by: Jeff Handley <jeffhandley@users.noreply.github.com>
Co-authored-by: Daniel Roth <daroth@microsoft.com>
Co-authored-by: Victor Dibia <chuvidi2003@gmail.com>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Shawn Henry <sphenry@gmail.com>
Co-authored-by: Javier Calvarro Nelson <jacalvar@microsoft.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Korolev Dmitry <deagle.gross@gmail.com>
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Reuben Bond <reuben.bond@gmail.com>
Co-authored-by: Tao Chen <taochen@microsoft.com>
Co-authored-by: wuweng <wuweng@microsoft.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
Co-authored-by: Giles Odigwe <79032838+giles17@users.noreply.github.com>
Co-authored-by: Daniel Cazzulino <daniel@cazzulino.com>
Co-authored-by: kzu <169707+kzu@users.noreply.github.com>
This commit is contained in:
Dmytro Struk
2025-11-11 23:12:09 -08:00
committed by GitHub
co-authored by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Chris Copilot kzu copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Reuben Bond Peter Ibekwe Jeff Handley Daniel Roth Victor Dibia Mark Wallace Shawn Henry Javier Calvarro Nelson Evan Mattson Eduard van Valkenburg Korolev Dmitry westey dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Reuben Bond Tao Chen wuweng Roger Barreto SergeyMenshykh Copilot Jacob Alber Giles Odigwe Daniel Cazzulino
parent 85fcd230bf
commit 361c47f30f
231 changed files with 19658 additions and 4142 deletions
+33 -1
View File
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b251111] - 2025-11-11
### Added
- **agent-framework-core**: Add OpenAI Responses Image Generation Stream Support with partial images and unit tests ([#1853](https://github.com/microsoft/agent-framework/pull/1853))
- **agent-framework-ag-ui**: Add concrete AGUIChatClient implementation ([#2072](https://github.com/microsoft/agent-framework/pull/2072))
### Fixed
- **agent-framework-a2a**: Use the last entry in the task history to avoid empty responses ([#2101](https://github.com/microsoft/agent-framework/pull/2101))
- **agent-framework-core**: Fix MCP Tool Parameter Descriptions not propagated to LLMs ([#1978](https://github.com/microsoft/agent-framework/pull/1978))
- **agent-framework-core**: Handle agent user input request in AgentExecutor ([#2022](https://github.com/microsoft/agent-framework/pull/2022))
- **agent-framework-core**: Fix Model ID attribute not showing up in `invoke_agent` span ([#2061](https://github.com/microsoft/agent-framework/pull/2061))
- **agent-framework-core**: Fix underlying tool choice bug and enable return to previous Handoff subagent ([#2037](https://github.com/microsoft/agent-framework/pull/2037))
## [1.0.0b251108] - 2025-11-08
### Added
- **agent-framework-devui**: Add OpenAI Responses API proxy support + HIL (Human-in-the-Loop) for Workflows ([#1737](https://github.com/microsoft/agent-framework/pull/1737))
- **agent-framework-purview**: Add Caching and background processing in Python Purview Middleware ([#1844](https://github.com/microsoft/agent-framework/pull/1844))
### Changed
- **agent-framework-devui**: Use metadata.entity_id instead of model field ([#1984](https://github.com/microsoft/agent-framework/pull/1984))
- **agent-framework-devui**: Serialize workflow input as string to maintain conformance with OpenAI Responses format ([#2021](https://github.com/microsoft/agent-framework/pull/2021))
## [1.0.0b251106.post1] - 2025-11-06
### Fixed
@@ -177,7 +204,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251104...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251111...HEAD
[1.0.0b251111]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251108...python-1.0.0b251111
[1.0.0b251108]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106.post1...python-1.0.0b251108
[1.0.0b251106.post1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106...python-1.0.0b251106.post1
[1.0.0b251106]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251105...python-1.0.0b251106
[1.0.0b251105]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251104...python-1.0.0b251105
[1.0.0b251104]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251028...python-1.0.0b251104
[1.0.0b251028]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251016...python-1.0.0b251028
[1.0.0b251016]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251007...python-1.0.0b251016
@@ -388,6 +388,17 @@ class A2AAgent(BaseAgent):
if task.artifacts is not None:
for artifact in task.artifacts:
messages.append(self._artifact_to_chat_message(artifact))
elif task.history is not None and len(task.history) > 0:
# Include the last history item as the agent response
history_item = task.history[-1]
contents = self._a2a_parts_to_contents(history_item.parts)
messages.append(
ChatMessage(
role=Role.ASSISTANT if history_item.role == A2ARole.agent else Role.USER,
contents=contents,
raw_representation=history_item,
)
)
return messages
+1 -1
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251105"
version = "1.0.0b251111"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+33 -1
View File
@@ -10,6 +10,8 @@ pip install agent-framework-ag-ui
## Quick Start
### Server (Host an AI Agent)
```python
from fastapi import FastAPI
from agent_framework import ChatAgent
@@ -23,6 +25,7 @@ agent = ChatAgent(
chat_client=AzureOpenAIChatClient(
endpoint="https://your-resource.openai.azure.com/",
deployment_name="gpt-4o-mini",
api_key="your-api-key",
),
)
@@ -33,9 +36,38 @@ add_agent_framework_fastapi_endpoint(app, agent, "/")
# Run with: uvicorn main:app --reload
```
### Client (Connect to an AG-UI Server)
```python
import asyncio
from agent_framework import TextContent
from agent_framework_ag_ui import AGUIChatClient
async def main():
async with AGUIChatClient(endpoint="http://localhost:8000/") as client:
# Stream responses
async for update in client.get_streaming_response("Hello!"):
for content in update.contents:
if isinstance(content, TextContent):
print(content.text, end="", flush=True)
print()
asyncio.run(main())
```
The `AGUIChatClient` supports:
- Streaming and non-streaming responses
- Hybrid tool execution (client-side + server-side tools)
- Automatic thread management for conversation continuity
- Integration with `ChatAgent` for client-side history management
## Documentation
- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building your first AG-UI server and client
- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building AG-UI servers and clients
- Server setup with FastAPI
- Client examples using `AGUIChatClient`
- Hybrid tool execution (client-side + server-side)
- Thread management and conversation continuity
- **[Examples](agent_framework_ag_ui_examples/)** - Complete examples for AG-UI features
## Features
@@ -5,6 +5,7 @@
import importlib.metadata
from ._agent import AgentFrameworkAgent
from ._client import AGUIChatClient
from ._confirmation_strategies import (
ConfirmationStrategy,
DefaultConfirmationStrategy,
@@ -13,6 +14,8 @@ from ._confirmation_strategies import (
TaskPlannerConfirmationStrategy,
)
from ._endpoint import add_agent_framework_fastapi_endpoint
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService
try:
__version__ = importlib.metadata.version(__name__)
@@ -22,6 +25,9 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"AgentFrameworkAgent",
"add_agent_framework_fastapi_endpoint",
"AGUIChatClient",
"AGUIEventConverter",
"AGUIHttpService",
"ConfirmationStrategy",
"DefaultConfirmationStrategy",
"TaskPlannerConfirmationStrategy",
@@ -0,0 +1,407 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI Chat Client implementation."""
import json
import logging
import uuid
from collections.abc import AsyncIterable, MutableSequence
from functools import wraps
from typing import Any, TypeVar, cast
import httpx
from agent_framework import (
AIFunction,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
DataContent,
FunctionCallContent,
)
from agent_framework._middleware import use_chat_middleware
from agent_framework._tools import use_function_invocation
from agent_framework._types import BaseContent, Contents
from agent_framework.observability import use_observability
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService
from ._message_adapters import agent_framework_messages_to_agui
from ._utils import convert_tools_to_agui_format
logger: logging.Logger = logging.getLogger(__name__)
class ServerFunctionCallContent(BaseContent):
"""Wrapper for server function calls to prevent client re-execution.
All function calls from the remote server are server-side executions.
This wrapper prevents @use_function_invocation from trying to execute them again.
"""
function_call_content: FunctionCallContent
def __init__(self, function_call_content: FunctionCallContent) -> None:
"""Initialize with the function call content."""
super().__init__(type="server_function_call")
self.function_call_content = function_call_content
def _unwrap_server_function_call_contents(contents: MutableSequence[Contents | dict[str, Any]]) -> None:
"""Replace ServerFunctionCallContent instances with their underlying call content."""
for idx, content in enumerate(contents):
if isinstance(content, ServerFunctionCallContent):
contents[idx] = content.function_call_content # type: ignore[assignment]
TBaseChatClient = TypeVar("TBaseChatClient", bound=type[BaseChatClient])
def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseChatClient:
"""Class decorator that unwraps server-side function calls after tool handling."""
original_get_streaming_response = chat_client.get_streaming_response
@wraps(original_get_streaming_response)
async def streaming_wrapper(self, *args: Any, **kwargs: Any) -> AsyncIterable[ChatResponseUpdate]:
async for update in original_get_streaming_response(self, *args, **kwargs):
_unwrap_server_function_call_contents(cast(MutableSequence[Contents | dict[str, Any]], update.contents))
yield update
chat_client.get_streaming_response = streaming_wrapper # type: ignore[assignment]
original_get_response = chat_client.get_response
@wraps(original_get_response)
async def response_wrapper(self, *args: Any, **kwargs: Any) -> ChatResponse:
response = await original_get_response(self, *args, **kwargs)
if response.messages:
for message in response.messages:
_unwrap_server_function_call_contents(
cast(MutableSequence[Contents | dict[str, Any]], message.contents)
)
return response
chat_client.get_response = response_wrapper # type: ignore[assignment]
return chat_client
@_apply_server_function_call_unwrap
@use_function_invocation
@use_observability
@use_chat_middleware
class AGUIChatClient(BaseChatClient):
"""Chat client for communicating with AG-UI compliant servers.
This client implements the BaseChatClient interface and automatically handles:
- Thread ID management for conversation continuity
- State synchronization between client and server
- Server-Sent Events (SSE) streaming
- Event conversion to Agent Framework types
Important: Message History Management
This client sends exactly the messages it receives to the server. It does NOT
automatically maintain conversation history. The server must handle history via thread_id.
For stateless servers: Use ChatAgent wrapper which will send full message history on each
request. However, even with ChatAgent, the server must echo back all context for the
agent to maintain history across turns.
Important: Tool Handling (Hybrid Execution - matches .NET)
1. Client tool metadata sent to server - LLM knows about both client and server tools
2. Server has its own tools that execute server-side
3. When LLM calls a client tool, @use_function_invocation executes it locally
4. Both client and server tools work together (hybrid pattern)
The wrapping ChatAgent's @use_function_invocation handles client tool execution
automatically when the server's LLM decides to call them.
Examples:
Direct usage (server manages thread history):
.. code-block:: python
from agent_framework.ag_ui import AGUIChatClient
client = AGUIChatClient(endpoint="http://localhost:8888/")
# First message - thread ID auto-generated
response = await client.get_response("Hello!")
thread_id = response.additional_properties.get("thread_id")
# Second message - server retrieves history using thread_id
response2 = await client.get_response(
"How are you?",
metadata={"thread_id": thread_id}
)
Recommended usage with ChatAgent (client manages history):
.. code-block:: python
from agent_framework import ChatAgent
from agent_framework.ag_ui import AGUIChatClient
client = AGUIChatClient(endpoint="http://localhost:8888/")
agent = ChatAgent(name="assistant", client=client)
thread = await agent.get_new_thread()
# ChatAgent automatically maintains history and sends full context
response = await agent.run("Hello!", thread=thread)
response2 = await agent.run("How are you?", thread=thread)
Streaming usage:
.. code-block:: python
async for update in client.get_streaming_response("Tell me a story"):
if update.contents:
for content in update.contents:
if hasattr(content, "text"):
print(content.text, end="", flush=True)
Context manager:
.. code-block:: python
async with AGUIChatClient(endpoint="http://localhost:8888/") as client:
response = await client.get_response("Hello!")
print(response.messages[0].text)
"""
OTEL_PROVIDER_NAME = "agui"
def __init__(
self,
*,
endpoint: str,
http_client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the AG-UI chat client.
Args:
endpoint: The AG-UI server endpoint URL (e.g., "http://localhost:8888/")
http_client: Optional httpx.AsyncClient instance. If None, one will be created.
timeout: Request timeout in seconds (default: 60.0)
additional_properties: Additional properties to store
**kwargs: Additional arguments passed to BaseChatClient
"""
super().__init__(additional_properties=additional_properties, **kwargs)
self._http_service = AGUIHttpService(
endpoint=endpoint,
http_client=http_client,
timeout=timeout,
)
async def close(self) -> None:
"""Close the HTTP client."""
await self._http_service.close()
async def __aenter__(self) -> "AGUIChatClient":
"""Enter async context manager."""
return self
async def __aexit__(self, *args: Any) -> None:
"""Exit async context manager."""
await self.close()
def _register_server_tool_placeholder(self, tool_name: str) -> None:
"""Register a declaration-only placeholder so function invocation skips execution."""
config = getattr(self, "function_invocation_configuration", None)
if not config:
return
if any(getattr(tool, "name", None) == tool_name for tool in config.additional_tools):
return
placeholder: AIFunction[Any, Any] = AIFunction(
name=tool_name,
description="Server-managed tool placeholder (AG-UI)",
func=None,
)
config.additional_tools = list(config.additional_tools) + [placeholder]
registered: set[str] = getattr(self, "_registered_server_tools", set())
registered.add(tool_name)
self._registered_server_tools = registered # type: ignore[attr-defined]
from agent_framework._logging import get_logger
logger = get_logger()
logger.debug(f"[AGUIChatClient] Registered server placeholder: {tool_name}")
def _extract_state_from_messages(
self, messages: MutableSequence[ChatMessage]
) -> tuple[list[ChatMessage], dict[str, Any] | None]:
"""Extract state from last message if present.
Args:
messages: List of chat messages
Returns:
Tuple of (messages_without_state, state_dict)
"""
if not messages:
return list(messages), None
last_message = messages[-1]
for content in last_message.contents:
if isinstance(content, DataContent) and content.media_type == "application/json":
try:
uri = content.uri
if uri.startswith("data:application/json;base64,"):
import base64
encoded_data = uri.split(",", 1)[1]
decoded_bytes = base64.b64decode(encoded_data)
state = json.loads(decoded_bytes.decode("utf-8"))
messages_without_state = list(messages[:-1]) if len(messages) > 1 else []
return messages_without_state, state
except (json.JSONDecodeError, ValueError, KeyError) as e:
from agent_framework._logging import get_logger
logger = get_logger()
logger.warning(f"Failed to extract state from message: {e}")
return list(messages), None
def _convert_messages_to_agui_format(self, messages: list[ChatMessage]) -> list[dict[str, Any]]:
"""Convert Agent Framework messages to AG-UI format.
Args:
messages: List of ChatMessage objects
Returns:
List of AG-UI formatted message dictionaries
"""
return agent_framework_messages_to_agui(messages)
def _get_thread_id(self, chat_options: ChatOptions) -> str:
"""Get or generate thread ID from chat options.
Args:
chat_options: Chat options containing metadata
Returns:
Thread ID string
"""
thread_id = None
if chat_options.metadata:
thread_id = chat_options.metadata.get("thread_id")
if not thread_id:
thread_id = f"thread_{uuid.uuid4().hex}"
return thread_id
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
"""Internal method to get non-streaming response.
Keyword Args:
messages: List of chat messages
chat_options: Chat options for the request
**kwargs: Additional keyword arguments
Returns:
ChatResponse object
"""
return await ChatResponse.from_chat_response_generator(
self._inner_get_streaming_response(
messages=messages,
chat_options=chat_options,
**kwargs,
)
)
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Internal method to get streaming response.
Keyword Args:
messages: List of chat messages
chat_options: Chat options for the request
**kwargs: Additional keyword arguments
Yields:
ChatResponseUpdate objects
"""
messages_to_send, state = self._extract_state_from_messages(messages)
thread_id = self._get_thread_id(chat_options)
run_id = f"run_{uuid.uuid4().hex}"
agui_messages = self._convert_messages_to_agui_format(messages_to_send)
# Send client tools to server so LLM knows about them
# Client tools execute via ChatAgent's @use_function_invocation wrapper
agui_tools = convert_tools_to_agui_format(chat_options.tools)
# Build set of client tool names (matches .NET clientToolSet)
# Used to distinguish client vs server tools in response stream
client_tool_set: set[str] = set()
if chat_options.tools:
for tool in chat_options.tools:
if hasattr(tool, "name"):
client_tool_set.add(tool.name) # type: ignore[arg-type]
self._last_client_tool_set = client_tool_set # type: ignore[attr-defined]
logger.debug(
"[AGUIChatClient] Preparing request",
extra={
"thread_id": thread_id,
"run_id": run_id,
"client_tools": list(client_tool_set),
"messages": [msg.text for msg in messages_to_send if msg.text],
},
)
logger.debug(f"[AGUIChatClient] Client tool set: {client_tool_set}")
converter = AGUIEventConverter()
async for event in self._http_service.post_run(
thread_id=thread_id,
run_id=run_id,
messages=agui_messages,
state=state,
tools=agui_tools,
):
logger.debug(f"[AGUIChatClient] Raw AG-UI event: {event}")
update = converter.convert_event(event)
if update is not None:
logger.debug(
"[AGUIChatClient] Converted update",
extra={"role": update.role, "contents": [type(c).__name__ for c in update.contents]},
)
# Distinguish client vs server tools
for i, content in enumerate(update.contents):
if isinstance(content, FunctionCallContent):
logger.debug(
f"[AGUIChatClient] Function call: {content.name}, in client_tool_set: {content.name in client_tool_set}"
)
if content.name in client_tool_set:
# Client tool - let @use_function_invocation execute it
if not content.additional_properties:
content.additional_properties = {}
content.additional_properties["agui_thread_id"] = thread_id
else:
# Server tool - wrap so @use_function_invocation ignores it
logger.debug(f"[AGUIChatClient] Wrapping server tool: {content.name}")
self._register_server_tool_placeholder(content.name)
update.contents[i] = ServerFunctionCallContent(content) # type: ignore
yield update
@@ -0,0 +1,209 @@
# Copyright (c) Microsoft. All rights reserved.
"""Event converter for AG-UI protocol events to Agent Framework types."""
from typing import Any
from agent_framework import (
ChatResponseUpdate,
ErrorContent,
FinishReason,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
)
class AGUIEventConverter:
"""Converter for AG-UI events to Agent Framework types.
Handles conversion of AG-UI protocol events to ChatResponseUpdate objects
while maintaining state, aggregating content, and tracking metadata.
"""
def __init__(self) -> None:
"""Initialize the converter with fresh state."""
self.current_message_id: str | None = None
self.current_tool_call_id: str | None = None
self.current_tool_name: str | None = None
self.accumulated_tool_args: str = ""
self.thread_id: str | None = None
self.run_id: str | None = None
def convert_event(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Convert a single AG-UI event to ChatResponseUpdate.
Args:
event: AG-UI event dictionary
Returns:
ChatResponseUpdate if event produces content, None otherwise
Examples:
RUN_STARTED event:
.. code-block:: python
converter = AGUIEventConverter()
event = {"type": "RUN_STARTED", "threadId": "t1", "runId": "r1"}
update = converter.convert_event(event)
assert update.additional_properties["thread_id"] == "t1"
TEXT_MESSAGE_CONTENT event:
.. code-block:: python
event = {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "Hello"}
update = converter.convert_event(event)
assert update.contents[0].text == "Hello"
"""
event_type = event.get("type", "")
if event_type == "RUN_STARTED":
return self._handle_run_started(event)
elif event_type == "TEXT_MESSAGE_START":
return self._handle_text_message_start(event)
elif event_type == "TEXT_MESSAGE_CONTENT":
return self._handle_text_message_content(event)
elif event_type == "TEXT_MESSAGE_END":
return self._handle_text_message_end(event)
elif event_type == "TOOL_CALL_START":
return self._handle_tool_call_start(event)
elif event_type == "TOOL_CALL_ARGS":
return self._handle_tool_call_args(event)
elif event_type == "TOOL_CALL_END":
return self._handle_tool_call_end(event)
elif event_type == "TOOL_CALL_RESULT":
return self._handle_tool_call_result(event)
elif event_type == "RUN_FINISHED":
return self._handle_run_finished(event)
elif event_type == "RUN_ERROR":
return self._handle_run_error(event)
return None
def _handle_run_started(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle RUN_STARTED event."""
self.thread_id = event.get("threadId")
self.run_id = event.get("runId")
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[],
additional_properties={
"thread_id": self.thread_id,
"run_id": self.run_id,
},
)
def _handle_text_message_start(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TEXT_MESSAGE_START event."""
self.current_message_id = event.get("messageId")
return ChatResponseUpdate(
role=Role.ASSISTANT,
message_id=self.current_message_id,
contents=[],
)
def _handle_text_message_content(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TEXT_MESSAGE_CONTENT event."""
message_id = event.get("messageId")
delta = event.get("delta", "")
if message_id != self.current_message_id:
self.current_message_id = message_id
return ChatResponseUpdate(
role=Role.ASSISTANT,
message_id=self.current_message_id,
contents=[TextContent(text=delta)],
)
def _handle_text_message_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TEXT_MESSAGE_END event."""
return None
def _handle_tool_call_start(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TOOL_CALL_START event."""
self.current_tool_call_id = event.get("toolCallId")
self.current_tool_name = event.get("toolName") or event.get("toolCallName") or event.get("tool_call_name")
self.accumulated_tool_args = ""
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments="",
)
],
)
def _handle_tool_call_args(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TOOL_CALL_ARGS event."""
delta = event.get("delta", "")
self.accumulated_tool_args += delta
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments=delta,
)
],
)
def _handle_tool_call_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TOOL_CALL_END event."""
self.accumulated_tool_args = ""
return None
def _handle_tool_call_result(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TOOL_CALL_RESULT event."""
tool_call_id = event.get("toolCallId", "")
result = event.get("result") if event.get("result") is not None else event.get("content")
return ChatResponseUpdate(
role=Role.TOOL,
contents=[
FunctionResultContent(
call_id=tool_call_id,
result=result,
)
],
)
def _handle_run_finished(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle RUN_FINISHED event."""
return ChatResponseUpdate(
role=Role.ASSISTANT,
finish_reason=FinishReason.STOP,
contents=[],
additional_properties={
"thread_id": self.thread_id,
"run_id": self.run_id,
},
)
def _handle_run_error(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle RUN_ERROR event."""
error_message = event.get("message", "Unknown error")
return ChatResponseUpdate(
role=Role.ASSISTANT,
finish_reason=FinishReason.CONTENT_FILTER,
contents=[
ErrorContent(
message=error_message,
error_code="RUN_ERROR",
)
],
additional_properties={
"thread_id": self.thread_id,
"run_id": self.run_id,
},
)
@@ -107,7 +107,7 @@ class AgentFrameworkEventBridge:
# Skip text content if we're about to emit confirm_changes
# The summary should only appear after user confirms
if self.should_stop_after_confirm:
logger.debug(" >>> Skipping text content - waiting for confirm_changes response")
logger.debug("Skipping text content - waiting for confirm_changes response")
# Save the summary text to show after confirmation
self.suppressed_summary += content.text
continue
@@ -156,7 +156,7 @@ class AgentFrameworkEventBridge:
tool_call_name=content.name,
parent_message_id=self.current_message_id,
)
logger.info(f" >>> Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'")
logger.info(f"Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'")
events.append(tool_start_event)
# Track tool call for MessagesSnapshotEvent
@@ -186,7 +186,7 @@ class AgentFrameworkEventBridge:
# If it's a dict, convert to JSON
delta_str = json.dumps(content.arguments)
logger.info(f" >>> Emitting ToolCallArgsEvent with delta: {delta_str!r}..., id='{tool_call_id}'")
logger.info(f"Emitting ToolCallArgsEvent with delta: {delta_str!r}..., id='{tool_call_id}'")
args_event = ToolCallArgsEvent(
tool_call_id=tool_call_id,
delta=delta_str,
@@ -211,7 +211,7 @@ class AgentFrameworkEventBridge:
self.streaming_tool_args += json.dumps(content.arguments)
logger.debug(
f" >>> Predictive state: accumulated {len(self.streaming_tool_args)} chars for tool '{self.current_tool_call_name}'"
f"Predictive state: accumulated {len(self.streaming_tool_args)} chars for tool '{self.current_tool_call_name}'"
)
# Try to parse accumulated arguments (may be incomplete JSON)
@@ -262,11 +262,11 @@ class AgentFrameworkEventBridge:
else str(partial_value)
)
logger.info(
f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
f"StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
f"op=replace, path=/{state_key}, value={value_preview}"
)
elif self.state_delta_count % 100 == 0:
logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted")
logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted")
events.append(state_delta_event)
self.last_emitted_state[state_key] = partial_value
@@ -312,11 +312,11 @@ class AgentFrameworkEventBridge:
else str(state_value)
)
logger.info(
f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
f"StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
f"op=replace, path=/{state_key}, value={value_preview}"
)
elif self.state_delta_count % 100 == 0: # Also log every 100th
logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted")
logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted")
events.append(state_delta_event)
@@ -360,7 +360,7 @@ class AgentFrameworkEventBridge:
],
)
logger.info(
f" >>> Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}"
f"Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}"
)
events.append(state_delta_event)
@@ -376,13 +376,13 @@ class AgentFrameworkEventBridge:
end_event = ToolCallEndEvent(
tool_call_id=content.call_id,
)
logger.info(f" >>> Emitting ToolCallEndEvent for completed tool call '{content.call_id}'")
logger.info(f"Emitting ToolCallEndEvent for completed tool call '{content.call_id}'")
events.append(end_event)
# Log total StateDeltaEvent count for this tool call
if self.state_delta_count > 0:
logger.info(
f" >>> Tool call '{content.call_id}' complete: emitted {self.state_delta_count} StateDeltaEvents total"
f"Tool call '{content.call_id}' complete: emitted {self.state_delta_count} StateDeltaEvents total"
)
# Reset streaming accumulator and counter for next tool call
@@ -410,11 +410,13 @@ class AgentFrameworkEventBridge:
events.append(result_event)
# Track tool result for MessagesSnapshotEvent
# AG-UI protocol expects: { role: "tool", toolCallId: ..., content: ... }
# Use camelCase for Pydantic's alias_generator=to_camel
self.tool_results.append(
{
"id": result_message_id,
"role": "tool",
"tool_call_id": content.call_id,
"toolCallId": content.call_id,
"content": result_content,
}
)
@@ -422,6 +424,9 @@ class AgentFrameworkEventBridge:
# Emit MessagesSnapshotEvent with the complete conversation including tool calls and results
# This is required for CopilotKit's useCopilotAction to detect tool result
if self.pending_tool_calls and self.tool_results:
# Import message adapter
from ._message_adapters import agent_framework_messages_to_agui
# Build assistant message with tool_calls
assistant_message = {
"id": generate_event_id(),
@@ -429,14 +434,19 @@ class AgentFrameworkEventBridge:
"tool_calls": self.pending_tool_calls.copy(), # Copy the accumulated tool calls
}
# Convert Agent Framework messages to AG-UI format (adds required 'id' field)
converted_input_messages = agent_framework_messages_to_agui(self.input_messages)
# Build complete messages array: input messages + assistant message + tool results
all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy()
all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy()
# Emit MessagesSnapshotEvent using the proper event type
# Note: messages are dict[str, Any] but Pydantic will validate them as Message types
messages_snapshot_event = MessagesSnapshotEvent(
type=EventType.MESSAGES_SNAPSHOT, messages=all_messages
type=EventType.MESSAGES_SNAPSHOT,
messages=all_messages, # type: ignore[arg-type]
)
logger.info(f" >>> Emitting MessagesSnapshotEvent with {len(all_messages)} messages")
logger.info(f"Emitting MessagesSnapshotEvent with {len(all_messages)} messages")
events.append(messages_snapshot_event)
# After tool execution, emit StateSnapshotEvent if we have pending state updates
@@ -466,7 +476,7 @@ class AgentFrameworkEventBridge:
# If so, emit a confirm_changes tool call for the UI modal
tool_was_predictive = False
logger.debug(
f" >>> Checking predictive state: current_tool='{self.current_tool_call_name}', "
f"Checking predictive state: current_tool='{self.current_tool_call_name}', "
f"predict_config={list(self.predict_state_config.keys()) if self.predict_state_config else 'None'}"
)
for state_key, config in self.predict_state_config.items():
@@ -474,7 +484,7 @@ class AgentFrameworkEventBridge:
# We need to match against self.current_tool_call_name
if self.current_tool_call_name and config["tool"] == self.current_tool_call_name:
logger.info(
f" >>> Tool '{self.current_tool_call_name}' matches predictive config for state key '{state_key}'"
f"Tool '{self.current_tool_call_name}' matches predictive config for state key '{state_key}'"
)
tool_was_predictive = True
break
@@ -483,7 +493,7 @@ class AgentFrameworkEventBridge:
# Emit confirm_changes tool call sequence
confirm_call_id = generate_event_id()
logger.info(" >>> Emitting confirm_changes tool call for predictive update")
logger.info("Emitting confirm_changes tool call for predictive update")
# Track confirm_changes tool call for MessagesSnapshotEvent (so it persists after RUN_FINISHED)
self.pending_tool_calls.append(
@@ -518,6 +528,9 @@ class AgentFrameworkEventBridge:
events.append(confirm_end)
# Emit MessagesSnapshotEvent so confirm_changes persists after RUN_FINISHED
# Import message adapter
from ._message_adapters import agent_framework_messages_to_agui
# Build assistant message with pending confirm_changes tool call
assistant_message = {
"id": generate_event_id(),
@@ -525,23 +538,28 @@ class AgentFrameworkEventBridge:
"tool_calls": self.pending_tool_calls.copy(), # Includes confirm_changes
}
# Convert Agent Framework messages to AG-UI format (adds required 'id' field)
converted_input_messages = agent_framework_messages_to_agui(self.input_messages)
# Build complete messages array: input messages + assistant message + any tool results
all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy()
all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy()
# Emit MessagesSnapshotEvent
# Note: messages are dict[str, Any] but Pydantic will validate them as Message types
messages_snapshot_event = MessagesSnapshotEvent(
type=EventType.MESSAGES_SNAPSHOT, messages=all_messages
type=EventType.MESSAGES_SNAPSHOT,
messages=all_messages, # type: ignore[arg-type]
)
logger.info(
f" >>> Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages"
f"Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages"
)
events.append(messages_snapshot_event)
# Set flag to stop the run after this - we're waiting for user response
self.should_stop_after_confirm = True
logger.info(" >>> Set flag to stop run after confirm_changes")
logger.info("Set flag to stop run after confirm_changes")
elif tool_was_predictive:
logger.info(" >>> Skipping confirm_changes - require_confirmation is False")
logger.info("Skipping confirm_changes - require_confirmation is False")
# Clear pending updates and reset tool name tracker
self.pending_state_updates.clear()
@@ -580,7 +598,7 @@ class AgentFrameworkEventBridge:
# Update current state
self.current_state[state_key] = state_value
logger.info(
f" >>> Emitting StateSnapshotEvent for key '{state_key}', value type: {type(state_value)}"
f"Emitting StateSnapshotEvent for key '{state_key}', value type: {type(state_value)}"
)
# Emit state snapshot
@@ -596,7 +614,7 @@ class AgentFrameworkEventBridge:
tool_call_id=content.function_call.call_id,
)
logger.info(
f" >>> Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'"
f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'"
)
events.append(end_event)
@@ -615,7 +633,7 @@ class AgentFrameworkEventBridge:
},
},
)
logger.info(f" >>> Emitting function_approval_request custom event for '{content.function_call.name}'")
logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'")
events.append(approval_event)
return events
@@ -0,0 +1,157 @@
# Copyright (c) Microsoft. All rights reserved.
"""HTTP service for AG-UI protocol communication."""
import json
import logging
from collections.abc import AsyncIterable
from typing import Any
import httpx
logger = logging.getLogger(__name__)
class AGUIHttpService:
"""HTTP service for AG-UI protocol communication.
Handles HTTP POST requests and Server-Sent Events (SSE) stream parsing
for the AG-UI protocol.
Examples:
Basic usage:
.. code-block:: python
service = AGUIHttpService("http://localhost:8888/")
async for event in service.post_run(
thread_id="thread_123",
run_id="run_456",
messages=[{"role": "user", "content": "Hello"}]
):
print(event["type"])
With context manager:
.. code-block:: python
async with AGUIHttpService("http://localhost:8888/") as service:
async for event in service.post_run(...):
print(event)
"""
def __init__(
self,
endpoint: str,
http_client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
) -> None:
"""Initialize the HTTP service.
Args:
endpoint: AG-UI server endpoint URL (e.g., "http://localhost:8888/")
http_client: Optional httpx AsyncClient. If None, creates a new one.
timeout: Request timeout in seconds (default: 60.0)
"""
self.endpoint = endpoint.rstrip("/")
self._owns_client = http_client is None
self.http_client = http_client or httpx.AsyncClient(timeout=timeout)
async def post_run(
self,
thread_id: str,
run_id: str,
messages: list[dict[str, Any]],
state: dict[str, Any] | None = None,
tools: list[dict[str, Any]] | None = None,
) -> AsyncIterable[dict[str, Any]]:
"""Post a run request and stream AG-UI events.
Args:
thread_id: Thread identifier for conversation continuity
run_id: Unique run identifier
messages: List of messages in AG-UI format
state: Optional state object to send to server
tools: Optional list of tools available to the agent
Yields:
AG-UI event dictionaries parsed from SSE stream
Raises:
httpx.HTTPStatusError: If the HTTP request fails
ValueError: If SSE parsing encounters invalid data
Examples:
.. code-block:: python
service = AGUIHttpService("http://localhost:8888/")
async for event in service.post_run(
thread_id="thread_abc",
run_id="run_123",
messages=[{"role": "user", "content": "Hello"}],
state={"user_context": {"name": "Alice"}}
):
if event["type"] == "TEXT_MESSAGE_CONTENT":
print(event["delta"])
"""
# Build request payload
request_data: dict[str, Any] = {
"thread_id": thread_id,
"run_id": run_id,
"messages": messages,
}
if state is not None:
request_data["state"] = state
if tools is not None:
request_data["tools"] = tools
logger.debug(
f"Posting run to {self.endpoint}: thread_id={thread_id}, run_id={run_id}, "
f"messages={len(messages)}, has_state={state is not None}, has_tools={tools is not None}"
)
# Stream the response using SSE
async with self.http_client.stream(
"POST",
self.endpoint,
json=request_data,
headers={"Accept": "text/event-stream"},
) as response:
try:
response.raise_for_status()
except httpx.HTTPStatusError as e:
logger.error(f"HTTP request failed: {e.response.status_code} - {e.response.text}")
raise
async for line in response.aiter_lines():
# Parse Server-Sent Events format
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
try:
event = json.loads(data)
logger.debug(f"Received event: {event.get('type', 'UNKNOWN')}")
yield event
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse SSE data: {data}. Error: {e}")
# Continue processing other events instead of failing
continue
async def close(self) -> None:
"""Close the HTTP client if owned by this service.
Only closes the client if it was created by this service instance.
If an external client was provided, it remains the caller's
responsibility to close it.
"""
if self._owns_client and self.http_client:
await self.http_client.aclose()
async def __aenter__(self) -> "AGUIHttpService":
"""Enter async context manager."""
return self
async def __aexit__(self, *args: Any) -> None:
"""Exit async context manager and clean up resources."""
await self.close()
@@ -2,12 +2,13 @@
"""Message format conversion between AG-UI and Agent Framework."""
from typing import Any
from typing import Any, cast
from agent_framework import (
ChatMessage,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
)
@@ -46,7 +47,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
result_content = msg.get("result", msg.get("content", ""))
chat_msg = ChatMessage(
role=Role.ASSISTANT, # Tool results are assistant messages
role=Role.TOOL, # Tool results must be tool role
contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)],
)
@@ -56,6 +57,42 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
result.append(chat_msg)
continue
# If assistant message includes tool calls, convert to FunctionCallContent(s)
tool_calls = msg.get("tool_calls") or msg.get("toolCalls")
if tool_calls:
contents: list[Any] = []
# Include any assistant text content if present
content_text = msg.get("content")
if isinstance(content_text, str) and content_text:
contents.append(TextContent(text=content_text))
# Convert each tool call entry
for tc in tool_calls:
if not isinstance(tc, dict):
continue
# Cast to typed dict for proper type inference
tc_dict = cast(dict[str, Any], tc)
tc_type = tc_dict.get("type")
if tc_type == "function":
func_data = tc_dict.get("function", {})
func_dict = cast(dict[str, Any], func_data) if isinstance(func_data, dict) else {}
call_id = str(tc_dict.get("id", ""))
name = str(func_dict.get("name", ""))
arguments = func_dict.get("arguments")
contents.append(
FunctionCallContent(
call_id=call_id,
name=name,
arguments=arguments,
)
)
chat_msg = ChatMessage(role=Role.ASSISTANT, contents=contents)
if "id" in msg:
chat_msg.message_id = msg["id"]
result.append(chat_msg)
continue
role_str = msg.get("role", "user")
# Handle tool result messages (with role="tool")
@@ -78,11 +115,11 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# Backend tool results have non-empty content WITHOUT "accepted" field
if tool_call_id and result_content and not is_approval:
# Backend tool execution - convert to FunctionResultContent
# Tool execution result - convert to FunctionResultContent with correct role
from agent_framework import FunctionResultContent
chat_msg = ChatMessage(
role=Role.ASSISTANT, # Tool results are assistant messages
role=Role.TOOL,
contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)],
)
@@ -97,9 +134,8 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
chat_msg = ChatMessage(
role=Role.USER, # Approval responses are user messages
contents=[TextContent(text=content)],
additional_properties={"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")},
)
# Mark this as a tool result so we can detect it later
chat_msg.metadata = {"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")} # type: ignore[attr-defined]
if "id" in msg:
chat_msg.message_id = msg["id"]
@@ -112,7 +148,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# Check if this message contains function approvals
if "function_approvals" in msg and msg["function_approvals"]:
# Convert function approvals to FunctionApprovalResponseContent
contents: list[Any] = []
approval_contents: list[Any] = []
for approval in msg["function_approvals"]:
# Create FunctionCallContent with the modified arguments
func_call = FunctionCallContent(
@@ -127,9 +163,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
id=approval.get("id", ""),
function_call=func_call,
)
contents.append(approval_response)
approval_contents.append(approval_response)
chat_msg = ChatMessage(role=role, contents=contents) # type: ignore[arg-type]
chat_msg = ChatMessage(role=role, contents=approval_contents) # type: ignore[arg-type]
else:
# Regular text message
content = msg.get("content", "")
@@ -146,21 +182,44 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
return result
def agent_framework_messages_to_agui(messages: list[ChatMessage]) -> list[dict[str, Any]]:
def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert Agent Framework messages to AG-UI format.
Args:
messages: List of Agent Framework ChatMessage objects
messages: List of Agent Framework ChatMessage objects or AG-UI dicts (already converted)
Returns:
List of AG-UI message dictionaries
"""
from ._utils import generate_event_id
result: list[dict[str, Any]] = []
for msg in messages:
# If already a dict (AG-UI format), ensure it has an ID and normalize keys for Pydantic
if isinstance(msg, dict):
# Always work on a copy to avoid mutating input
normalized_msg = msg.copy()
# Ensure ID exists
if "id" not in normalized_msg:
normalized_msg["id"] = generate_event_id()
# Normalize tool_call_id to toolCallId for Pydantic's alias_generator=to_camel
if normalized_msg.get("role") == "tool":
if "tool_call_id" in normalized_msg:
normalized_msg["toolCallId"] = normalized_msg["tool_call_id"]
del normalized_msg["tool_call_id"]
elif "toolCallId" not in normalized_msg:
# Tool message missing toolCallId - add empty string to satisfy schema
normalized_msg["toolCallId"] = ""
# Always append the normalized copy, not the original
result.append(normalized_msg)
continue
# Convert ChatMessage to AG-UI format
role = _FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user")
content_text = ""
tool_calls: list[dict[str, Any]] = []
tool_result_call_id: str | None = None
for content in msg.contents:
if isinstance(content, TextContent):
@@ -176,18 +235,32 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage]) -> list[dict[s
},
}
)
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)
agui_msg: dict[str, Any] = {
"id": msg.message_id if msg.message_id else generate_event_id(), # Always include id
"role": role,
"content": content_text,
}
if msg.message_id:
agui_msg["id"] = msg.message_id
if tool_calls:
agui_msg["tool_calls"] = tool_calls
# If this is a tool result message, add toolCallId (using camelCase for Pydantic)
if tool_result_call_id:
agui_msg["toolCallId"] = tool_result_call_id
# Tool result messages should have role="tool"
agui_msg["role"] = "tool"
result.append(agui_msg)
return result
@@ -16,9 +16,9 @@ from ag_ui.core import (
TextMessageEndEvent,
TextMessageStartEvent,
)
from agent_framework import AgentProtocol, AgentThread, TextContent
from agent_framework import AgentProtocol, AgentThread, ChatAgent, TextContent
from ._utils import generate_event_id
from ._utils import convert_agui_tools_to_agent_framework, generate_event_id
if TYPE_CHECKING:
from ._agent import AgentConfig
@@ -142,14 +142,10 @@ class HumanInTheLoopOrchestrator(Orchestrator):
True if last message is a tool result
"""
msg = context.last_message
if not msg or not hasattr(msg, "metadata"):
if not msg:
return False
metadata = getattr(msg, "metadata", None)
if not metadata:
return False
return bool(metadata.get("is_tool_result", False))
return bool(msg.additional_properties.get("is_tool_result", False))
async def run(
self,
@@ -274,8 +270,10 @@ class DefaultOrchestrator(Orchestrator):
current_state: dict[str, Any] = initial_state.copy() if initial_state else {}
# Check if agent uses structured outputs (response_format)
chat_options = getattr(context.agent, "chat_options", None)
response_format = getattr(chat_options, "response_format", None) if chat_options else None
# Use isinstance to narrow type for proper attribute access
response_format = None
if isinstance(context.agent, ChatAgent):
response_format = context.agent.chat_options.response_format
skip_text_content = response_format is not None
# Create event bridge
@@ -334,9 +332,8 @@ class DefaultOrchestrator(Orchestrator):
if context.messages:
await thread.on_new_messages(context.messages)
# Get the last message as the new input
new_message = context.last_message
if not new_message:
# Use the full incoming message batch to preserve tool-call adjacency
if not context.messages:
logger.warning("No messages provided in AG-UI input")
yield event_bridge.create_run_finished_event()
return
@@ -362,11 +359,68 @@ Never replace existing data - always append or merge."""
)
messages_to_run.append(state_context_msg)
messages_to_run.append(new_message)
# Preserve order from client to satisfy provider constraints (assistant tool_calls must
# immediately precede tool result messages). Using the full batch avoids reordering.
messages_to_run.extend(context.messages)
# Handle client tools for hybrid execution
# Client sends tool metadata, server merges with its own tools.
# Client tools have func=None (declaration-only), so @use_function_invocation
# will return the function call without executing (passes back to client).
from agent_framework import BaseChatClient
client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools"))
# Extract server tools - use type narrowing when possible
server_tools: list[Any] = []
if isinstance(context.agent, ChatAgent):
server_tools = context.agent.chat_options.tools or []
else:
# AgentProtocol allows duck-typed implementations - fallback to attribute access
# This supports test mocks and custom agent implementations
try:
chat_options_attr = getattr(context.agent, "chat_options", None)
if chat_options_attr is not None:
server_tools = getattr(chat_options_attr, "tools", None) or []
except AttributeError:
pass
# Register client tools as additional (declaration-only) so they are not executed on server
if client_tools:
if isinstance(context.agent, ChatAgent):
# Type-safe path for ChatAgent
chat_client = context.agent.chat_client
if (
isinstance(chat_client, BaseChatClient)
and chat_client.function_invocation_configuration is not None
):
chat_client.function_invocation_configuration.additional_tools = client_tools
logger.debug(
f"[TOOLS] Registered {len(client_tools)} client tools as additional_tools (declaration-only)"
)
else:
# Fallback for AgentProtocol implementations (test mocks, custom agents)
try:
chat_client_attr = getattr(context.agent, "chat_client", None)
if chat_client_attr is not None:
fic = getattr(chat_client_attr, "function_invocation_configuration", None)
if fic is not None:
fic.additional_tools = client_tools # type: ignore[attr-defined]
logger.debug(
f"[TOOLS] Registered {len(client_tools)} client tools as additional_tools (declaration-only)"
)
except AttributeError:
pass
combined_tools: list[Any] = []
if server_tools:
combined_tools.extend(server_tools)
if client_tools:
combined_tools.extend(client_tools)
# Collect all updates to get the final structured output
all_updates: list[Any] = []
async for update in context.agent.run_stream(messages_to_run, thread=thread):
async for update in context.agent.run_stream(messages_to_run, thread=thread, tools=combined_tools or None):
all_updates.append(update)
events = await event_bridge.from_agent_run_update(update)
for event in events:
@@ -374,7 +428,7 @@ Never replace existing data - always append or merge."""
# After agent completes, check if we should stop (waiting for user to confirm changes)
if event_bridge.should_stop_after_confirm:
logger.info(" >>> Stopping run after confirm_changes - waiting for user response")
logger.info("Stopping run after confirm_changes - waiting for user response")
yield event_bridge.create_run_finished_event()
return
@@ -4,10 +4,13 @@
import copy
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
def generate_event_id() -> str:
"""Generate a unique event ID."""
@@ -55,3 +58,109 @@ def make_json_safe(obj: Any) -> Any: # noqa: ANN401
if isinstance(obj, dict):
return {key: make_json_safe(value) for key, value in obj.items()} # type: ignore[misc]
return str(obj)
def convert_agui_tools_to_agent_framework(
agui_tools: list[dict[str, Any]] | None,
) -> list[AIFunction[Any, Any]] | None:
"""Convert AG-UI tool definitions to Agent Framework AIFunction declarations.
Creates declaration-only AIFunction instances (no executable implementation).
These are used to tell the LLM about available tools. The actual execution
happens on the client side via @use_function_invocation.
CRITICAL: These tools MUST have func=None so that declaration_only returns True.
This prevents the server from trying to execute client-side tools.
Args:
agui_tools: List of AG-UI tool definitions with name, description, parameters
Returns:
List of AIFunction declarations, or None if no tools provided
"""
if not agui_tools:
return None
result: list[AIFunction[Any, Any]] = []
for tool_def in agui_tools:
# Create declaration-only AIFunction (func=None means no implementation)
# When func=None, the declaration_only property returns True,
# which tells @use_function_invocation to return the function call
# without executing it (so it can be sent back to the client)
func: AIFunction[Any, Any] = AIFunction(
name=tool_def.get("name", ""),
description=tool_def.get("description", ""),
func=None, # CRITICAL: Makes declaration_only=True
input_model=tool_def.get("parameters", {}),
)
result.append(func)
return result
def convert_tools_to_agui_format(
tools: (
ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None
),
) -> list[dict[str, Any]] | None:
"""Convert tools to AG-UI format.
This sends only the metadata (name, description, JSON schema) to the server.
The actual executable implementation stays on the client side.
The @use_function_invocation decorator handles client-side execution when
the server requests a function.
Args:
tools: Tools to convert (single tool or sequence of tools)
Returns:
List of tool specifications in AG-UI format, or None if no tools provided
"""
if not tools:
return None
# Normalize to list
if not isinstance(tools, list):
tool_list: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = [tools] # type: ignore[list-item]
else:
tool_list = tools # type: ignore[assignment]
results: list[dict[str, Any]] = []
for tool in tool_list:
if isinstance(tool, dict):
# Already in dict format, pass through
results.append(tool) # type: ignore[arg-type]
elif isinstance(tool, AIFunction):
# Convert AIFunction to AG-UI tool format
results.append(
{
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters(),
}
)
elif callable(tool):
# Convert callable to AIFunction first, then to AG-UI format
from agent_framework import ai_function
ai_func = ai_function(tool)
results.append(
{
"name": ai_func.name,
"description": ai_func.description,
"parameters": ai_func.parameters(),
}
)
elif isinstance(tool, ToolProtocol):
# Handle other ToolProtocol implementations
# For now, we'll skip non-AIFunction tools as they may not have
# the parameters() method. This matches .NET behavior which only
# converts AIFunctionDeclaration instances.
continue
return results if results else None
@@ -14,7 +14,7 @@ pip install agent-framework-ag-ui
from fastapi import FastAPI
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
# Create your agent
agent = ChatAgent(
@@ -104,7 +104,7 @@ State is injected as system messages and updated via predictive state updates:
```python
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
# Create your agent
agent = ChatAgent(
@@ -141,7 +141,7 @@ Predictive state updates automatically stream tool arguments as optimistic state
```python
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
# Create your agent
agent = ChatAgent(
@@ -170,7 +170,7 @@ Provide domain-specific confirmation messages:
from typing import Any
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent, ConfirmationStrategy
from agent_framework.ag_ui import AgentFrameworkAgent, ConfirmationStrategy
class CustomConfirmationStrategy(ConfirmationStrategy):
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
@@ -216,7 +216,7 @@ def sensitive_action(param: str) -> str:
Add custom execution flows by implementing the Orchestrator pattern:
```python
from agent_framework_ag_ui._orchestrators import Orchestrator, ExecutionContext
from agent_framework.ag_ui._orchestrators import Orchestrator, ExecutionContext
class MyCustomOrchestrator(Orchestrator):
def can_handle(self, context: ExecutionContext) -> bool:
@@ -128,7 +128,7 @@ class TaskStepsAgentWithExecution:
import uuid
logger = logging.getLogger(__name__)
logger.info(">>> TaskStepsAgentWithExecution.run_agent() called - wrapper is active")
logger.info("TaskStepsAgentWithExecution.run_agent() called - wrapper is active")
# First, run the base agent to generate the plan - buffer text messages
final_state: dict[str, Any] | None = None
@@ -138,41 +138,41 @@ class TaskStepsAgentWithExecution:
async for event in self._base_agent.run_agent(input_data):
event_type_str = str(event.type) if hasattr(event, "type") else type(event).__name__
logger.info(f">>> Processing event: {event_type_str}")
logger.info(f"Processing event: {event_type_str}")
match event:
case StateSnapshotEvent(snapshot=snapshot):
final_state = snapshot
logger.info(f">>> Captured STATE_SNAPSHOT event with state: {final_state}")
logger.info(f"Captured STATE_SNAPSHOT event with state: {final_state}")
yield event
case RunFinishedEvent():
run_finished_event = event
logger.info(">>> Captured RUN_FINISHED event - will send after step execution and summary")
logger.info("Captured RUN_FINISHED event - will send after step execution and summary")
case ToolCallStartEvent(tool_call_id=call_id):
tool_call_id = call_id
logger.info(f">>> Captured tool_call_id: {tool_call_id}")
logger.info(f"Captured tool_call_id: {tool_call_id}")
yield event
case TextMessageStartEvent() | TextMessageContentEvent() | TextMessageEndEvent():
buffered_text_events.append(event)
logger.info(f">>> Buffered {event_type_str} from first LLM call")
logger.info(f"Buffered {event_type_str} from first LLM call")
case _:
logger.info(f">>> Yielding event immediately: {event_type_str}")
logger.info(f"Yielding event immediately: {event_type_str}")
yield event
logger.info(f">>> Base agent completed. Final state: {final_state}")
logger.info(f"Base agent completed. Final state: {final_state}")
# Now simulate executing the steps
if final_state and "steps" in final_state:
steps = final_state["steps"]
logger.info(f">>> Starting step execution simulation for {len(steps)} steps")
logger.info(f"Starting step execution simulation for {len(steps)} steps")
for i in range(len(steps)):
logger.info(f">>> Simulating execution of step {i + 1}/{len(steps)}: {steps[i].get('description')}")
logger.info(f"Simulating execution of step {i + 1}/{len(steps)}: {steps[i].get('description')}")
await asyncio.sleep(1.0) # Simulate work
# Update step to completed
steps[i]["status"] = "completed"
logger.info(f">>> Step {i + 1} marked as completed")
logger.info(f"Step {i + 1} marked as completed")
# Send delta event with manual JSON patch format
delta_event = StateDeltaEvent(
@@ -185,7 +185,7 @@ class TaskStepsAgentWithExecution:
}
],
)
logger.info(f">>> Yielding StateDeltaEvent for step {i + 1}")
logger.info(f"Yielding StateDeltaEvent for step {i + 1}")
yield delta_event
# Send final snapshot
@@ -193,11 +193,11 @@ class TaskStepsAgentWithExecution:
type=EventType.STATE_SNAPSHOT,
snapshot={"steps": steps},
)
logger.info(">>> Yielding final StateSnapshotEvent with all steps completed")
logger.info("Yielding final StateSnapshotEvent with all steps completed")
yield final_snapshot
# SECOND LLM call: Stream summary from chat client directly
logger.info(">>> Making SECOND LLM call to generate summary after step execution")
logger.info("Making SECOND LLM call to generate summary after step execution")
# Get the underlying chat agent and client
chat_agent = self._base_agent.agent # type: ignore
@@ -236,7 +236,7 @@ class TaskStepsAgentWithExecution:
)
# Stream the LLM response and manually emit text events
logger.info(">>> Calling chat client for summary")
logger.info("Calling chat client for summary")
message_id = str(uuid.uuid4())
@@ -268,7 +268,7 @@ class TaskStepsAgentWithExecution:
type=EventType.TEXT_MESSAGE_END,
message_id=message_id,
)
logger.info(f">>> Summary complete: {accumulated_text}")
logger.info(f"Summary complete: {accumulated_text}")
# Build complete message for persistence
summary_message = {
@@ -285,7 +285,7 @@ class TaskStepsAgentWithExecution:
messages=final_messages,
)
except Exception as e:
logger.error(f">>> Error generating summary: {e}")
logger.error(f"Error generating summary: {e}")
# Generate a new message ID for the error
error_message_id = str(uuid.uuid4())
# Yield TEXT_MESSAGE_START for error
@@ -306,11 +306,11 @@ class TaskStepsAgentWithExecution:
message_id=error_message_id,
)
else:
logger.warning(f">>> No steps found in final_state to execute. final_state={final_state}")
logger.warning(f"No steps found in final_state to execute. final_state={final_state}")
# Finally send the original RUN_FINISHED event
if run_finished_event:
logger.info(">>> Yielding original RUN_FINISHED event")
logger.info("Yielding original RUN_FINISHED event")
yield run_finished_event
+185 -429
View File
@@ -2,6 +2,135 @@
The AG-UI (Agent UI) protocol provides a standardized way for client applications to interact with AI agents over HTTP. This tutorial demonstrates how to build both server and client applications using the AG-UI protocol with Python.
## Quick Start - Client Examples
If you want to quickly try out the AG-UI client, we provide three ready-to-use examples:
### Basic Interactive Client (`client.py`)
A simple command-line chat client that demonstrates:
- Streaming responses in real-time
- Automatic thread management for conversation continuity
- Direct `AGUIChatClient` usage (caller manages message history)
**Run:**
```bash
python client.py
```
**Note:** This example sends only the current message to the server. The server is responsible for maintaining conversation history using the thread_id.
### Advanced Features Client (`client_advanced.py`)
Demonstrates advanced capabilities:
- Tool/function calling
- Both streaming and non-streaming responses
- Multi-turn conversations
- Error handling patterns
**Run:**
```bash
python client_advanced.py
```
**Note:** This example shows direct `AGUIChatClient` usage. Tool execution and conversation continuity depend on server-side configuration and capabilities.
### ChatAgent Integration (`client_with_agent.py`)
Best practice example using `ChatAgent` wrapper with **AgentThread**
- **AgentThread** maintains conversation state
- Client-side conversation history management via `thread.message_store`
- **Hybrid tool execution**: client-side + server-side tools simultaneously
- Full conversation history sent on each request
- Tool calling with conversation context
**To demonstrate hybrid tools:**
1. **Start server with server-side tool** (Terminal 1):
```bash
# Server has get_time_zone tool
python server.py
```
2. **Run client with client-side tool** (Terminal 2):
```bash
# Client has get_weather tool
python client_with_agent.py
```
All examples require a running AG-UI server (see Step 1 below for setup).
## Understanding AG-UI Architecture
### Thread Management
The AG-UI protocol supports two approaches to conversation history:
1. **Server-Managed Threads** (client.py, client_advanced.py)
- Client sends only the current message + thread_id
- Server maintains full conversation history
- Requires server to support stateful thread storage
- Lighter network payload
2. **Client-Managed History** (client_with_agent.py)
- Client maintains full conversation history locally
- Full message history sent with each request
- Works with any AG-UI server (stateful or stateless)
The `ChatAgent` wrapper (used in client_with_agent.py) collects messages from local storage and sends the full history to `AGUIChatClient`, which then forwards everything to the server.
### Tool/Function Calling
The AG-UI protocol supports **hybrid tool execution** - both client-side AND server-side tools can coexist in the same conversation.
**The Hybrid Pattern** (client_with_agent.py):
```
Client defines: Server defines:
- get_weather() - get_current_time()
- read_sensors() - get_server_forecast()
User: "What's the weather in SF and what time is it?"
ChatAgent sends: full history + tool definitions for get_weather, read_sensors
Server LLM decides: "I need get_weather('SF') and get_current_time()"
Server executes get_current_time() → "2025-11-11 14:30:00 UTC"
Server sends function call request → get_weather('SF')
ChatAgent intercepts get_weather call → executes locally
Client sends result → "Sunny, 72°F"
Server combines both results → "It's sunny and 72°F in SF, and the current time is 2:30 PM UTC"
Client receives final response
```
**How it works:**
1. **Client-Side Tools** (`client_with_agent.py`):
- Tools defined in ChatAgent's `tools` parameter execute locally
- Tool metadata (name, description, schema) sent to server for planning
- When server requests client tool → client intercepts → executes locally → sends result
2. **Server-Side Tools**:
- Defined in server agent's configuration
- Server executes directly without client involvement
- Results included in server's response
3. **Hybrid Pattern (Both Together)**:
- Server LLM sees ALL tool definitions (client + server)
- Decides which to use based on task
- Server tools execute server-side
- Client tools execute client-side
**Direct AGUIChatClient Usage** (client_advanced.py):
Even without ChatAgent wrapper, client-side tools work:
- Tools passed in ChatOptions execute locally
- Server can also have its own tools
- Hybrid execution works automatically
## What is AG-UI?
AG-UI is a protocol that enables:
@@ -35,13 +164,13 @@ The AG-UI server hosts your AI agent and exposes it via HTTP endpoints using Fas
### Install Required Packages
```bash
pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn
pip install agent-framework-ag-ui
```
Or using uv:
```bash
uv pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn
uv pip install agent-framework-ag-ui
```
### Server Code
@@ -57,17 +186,20 @@ import os
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from fastapi import FastAPI
# Read required configuration
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
deployment_name = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME")
api_key = os.environ.get("AZURE_OPENAI_API_KEY")
if not endpoint:
raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required")
if not deployment_name:
raise ValueError("AZURE_OPENAI_DEPLOYMENT_NAME environment variable is required")
if not api_key:
raise ValueError("AZURE_OPENAI_API_KEY environment variable is required")
# Create the AI agent
agent = ChatAgent(
@@ -76,6 +208,7 @@ agent = ChatAgent(
chat_client=AzureOpenAIChatClient(
endpoint=endpoint,
deployment_name=deployment_name,
api_key=api_key,
),
)
@@ -137,12 +270,14 @@ The server will start listening on `http://127.0.0.1:5100`.
## Step 2: Creating an AG-UI Client
The AG-UI client connects to the remote server and displays streaming responses.
The AG-UI client connects to the remote server and displays streaming responses. The `AGUIChatClient` is a built-in implementation that integrates with the Agent Framework's standard chat interface.
### Install Required Packages
The `AGUIChatClient` is included in the `agent-framework-ag-ui` package (already installed if you installed the server packages).
```bash
pip install httpx
pip install agent-framework-ag-ui
```
### Client Code
@@ -152,122 +287,61 @@ Create a file named `client.py`:
```python
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI client example."""
"""AG-UI client example using AGUIChatClient."""
import asyncio
import json
import os
from typing import AsyncIterator
import httpx
class AGUIClient:
"""Simple AG-UI protocol client."""
def __init__(self, server_url: str):
"""Initialize the client.
Args:
server_url: The AG-UI server endpoint URL
"""
self.server_url = server_url
self.thread_id: str | None = None
async def send_message(self, message: str) -> AsyncIterator[dict]:
"""Send a message and stream the response.
Args:
message: The user message to send
Yields:
AG-UI events from the server
"""
# Prepare the request
request_data = {
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": message},
]
}
# Include thread_id if we have one (for conversation continuity)
if self.thread_id:
request_data["thread_id"] = self.thread_id
# Stream the response
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream(
"POST",
self.server_url,
json=request_data,
headers={"Accept": "text/event-stream"},
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
# Parse Server-Sent Events format
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
try:
event = json.loads(data)
yield event
# Capture thread_id from RUN_STARTED event
if event.get("type") == "RUN_STARTED" and not self.thread_id:
self.thread_id = event.get("threadId")
except json.JSONDecodeError:
continue
from agent_framework import TextContent
from agent_framework.ag_ui import AGUIChatClient
async def main():
"""Main client loop."""
"""Main client loop demonstrating AGUIChatClient usage."""
# Get server URL from environment or use default
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/")
print(f"Connecting to AG-UI server at: {server_url}\n")
client = AGUIClient(server_url)
# Create client with context manager for automatic cleanup
async with AGUIChatClient(endpoint=server_url) as client:
thread_id: str | None = None
try:
while True:
# Get user input
message = input("\nUser (:q or quit to exit): ")
if not message.strip():
print("Request cannot be empty.")
continue
try:
while True:
# Get user input
message = input("\nUser (:q or quit to exit): ")
if not message.strip():
print("Request cannot be empty.")
continue
if message.lower() in (":q", "quit"):
break
if message.lower() in (":q", "quit"):
break
# Send message and display streaming response
print("\n", end="")
async for event in client.send_message(message):
event_type = event.get("type", "")
# Send message and stream the response
print("\nAssistant: ", end="", flush=True)
if event_type == "RUN_STARTED":
thread_id = event.get("threadId", "")
run_id = event.get("runId", "")
print(f"\033[93m[Run Started - Thread: {thread_id}, Run: {run_id}]\033[0m")
# Use metadata to maintain conversation continuity
metadata = {"thread_id": thread_id} if thread_id else None
elif event_type == "TEXT_MESSAGE_CONTENT":
# Stream text content in cyan
print(f"\033[96m{event.get('delta', '')}\033[0m", end="", flush=True)
async for update in client.get_streaming_response(message, metadata=metadata):
# Extract thread ID from first update
if not thread_id and update.additional_properties:
thread_id = update.additional_properties.get("thread_id")
if thread_id:
print(f"\n[Thread: {thread_id}]")
print("Assistant: ", end="", flush=True)
elif event_type == "RUN_FINISHED":
thread_id = event.get("threadId", "")
run_id = event.get("runId", "")
print(f"\n\033[92m[Run Finished - Thread: {thread_id}, Run: {run_id}]\033[0m")
# Stream text content as it arrives
for content in update.contents:
if isinstance(content, TextContent) and content.text:
print(content.text, end="", flush=True)
elif event_type == "RUN_ERROR":
error_message = event.get("message", "Unknown error")
print(f"\n\033[91m[Run Error - Message: {error_message}]\033[0m")
print() # New line after response
print()
except KeyboardInterrupt:
print("\n\nExiting...")
except Exception as e:
print(f"\n\033[91mAn error occurred: {e}\033[0m")
except KeyboardInterrupt:
print("\n\nExiting...")
except Exception as e:
print(f"\nAn error occurred: {e}")
if __name__ == "__main__":
@@ -276,17 +350,13 @@ if __name__ == "__main__":
### Key Concepts
- **Server-Sent Events (SSE)**: The protocol uses SSE format (`data: {json}\n\n`)
- **Event Types**: Different events provide metadata and content (all event types use UPPERCASE with underscores):
- `RUN_STARTED`: Signals the agent has started processing
- `TEXT_MESSAGE_START`: Signals the start of a text message from the agent
- `TEXT_MESSAGE_CONTENT`: Incremental text streamed from the agent (with `delta` field)
- `TEXT_MESSAGE_END`: Signals the end of a text message
- `RUN_FINISHED`: Signals successful completion
- `RUN_ERROR`: Error information if something goes wrong
- **Field Naming**: Event fields use camelCase (e.g., `threadId`, `runId`, `messageId`) when accessing JSON events
- **Thread Management**: The `threadId` maintains conversation context across requests
- **Client-Side Instructions**: System messages are sent from the client
- **`AGUIChatClient`**: Built-in client that implements the Agent Framework's `BaseChatClient` interface
- **Automatic Event Handling**: The client automatically converts AG-UI events to Agent Framework types
- **Thread Management**: Pass `thread_id` in metadata to maintain conversation context across requests
- **Streaming Responses**: Use `get_streaming_response()` for real-time streaming or `get_response()` for non-streaming
- **Context Manager**: Use `async with` for automatic cleanup of HTTP connections
- **Standard Interface**: Works with all Agent Framework patterns (ChatAgent, tools, etc.)
- **Hybrid Tool Execution**: Supports both client-side and server-side tools executing together in the same conversation
### Configure and Run the Client
@@ -312,327 +382,13 @@ Connecting to AG-UI server at: http://127.0.0.1:5100/
User (:q or quit to exit): What is the capital of France?
[Run Started - Thread: abc123, Run: xyz789]
The capital of France is Paris. It is known for its rich history, culture,
[Thread: abc123]
Assistant: The capital of France is Paris. It is known for its rich history, culture,
and iconic landmarks such as the Eiffel Tower and the Louvre Museum.
[Run Finished - Thread: abc123, Run: xyz789]
User (:q or quit to exit): Tell me a fun fact about space
[Run Started - Thread: abc123, Run: def456]
Here's a fun fact: A day on Venus is longer than its year! Venus takes
about 243 Earth days to rotate once on its axis, but only about 225 Earth
days to orbit the Sun.
[Run Finished - Thread: abc123, Run: def456]
User (:q or quit to exit): :q
```
### Color-Coded Output
The client displays different content types with distinct colors:
- **Yellow**: Run started notifications
- **Cyan**: Agent text responses (streamed in real-time)
- **Green**: Run completion notifications
- **Red**: Error messages
## Testing with curl (Optional)
Before running the client, you can test the server manually using curl:
```bash
curl -N http://127.0.0.1:5100/ \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"messages": [
{"role": "user", "content": "What is the capital of France?"}
]
}'
```
You should see Server-Sent Events streaming back:
```
data: {"type":"RUN_STARTED","threadId":"...","runId":"..."}
data: {"type":"TEXT_MESSAGE_START","messageId":"...","role":"assistant"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":"The"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"...","delta":" capital"}
...
data: {"type":"TEXT_MESSAGE_END","messageId":"..."}
data: {"type":"RUN_FINISHED","threadId":"...","runId":"..."}
```
## How It Works
### Server-Side Flow
1. Client sends HTTP POST request with messages
2. FastAPI endpoint receives the request
3. `AgentFrameworkAgent` wrapper orchestrates the execution
4. Agent processes the messages using Agent Framework
5. `AgentFrameworkEventBridge` converts agent updates to AG-UI events
6. Responses are streamed back as Server-Sent Events (SSE)
7. Connection closes when the run completes
### Client-Side Flow
1. Client sends HTTP POST request to server endpoint
2. Server responds with SSE stream
3. Client parses incoming `data:` lines as JSON events
4. Each event is displayed based on its type
5. `threadId` is captured for conversation continuity
6. Stream completes when `RUN_FINISHED` event arrives
### Protocol Details
The AG-UI protocol uses:
- **HTTP POST** for sending requests
- **Server-Sent Events (SSE)** for streaming responses
- **JSON** for event serialization
- **Thread IDs** for maintaining conversation context
- **Run IDs** for tracking individual executions
- **Event type naming**: UPPERCASE with underscores (e.g., `RUN_STARTED`, `TEXT_MESSAGE_CONTENT`)
- **Field naming**: camelCase (e.g., `threadId`, `runId`, `messageId`)
## Advanced Features
The Python AG-UI implementation supports all 7 AG-UI features:
### 1. Backend Tool Rendering
Add tools to your agent for backend execution:
```python
from typing import Any
from agent_framework import ChatAgent, ai_function
from agent_framework.azure import AzureOpenAIChatClient
@ai_function
def get_weather(location: str) -> dict[str, Any]:
"""Get weather for a location."""
return {"temperature": 72, "conditions": "sunny"}
agent = ChatAgent(
name="weather_agent",
instructions="Use tools to help users.",
chat_client=AzureOpenAIChatClient(
endpoint="https://your-resource.openai.azure.com/",
deployment_name="gpt-4o-mini",
),
tools=[get_weather],
)
```
The client will receive `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END`, and `TOOL_CALL_RESULT` events.
### 2. Human in the Loop
Request user confirmation before executing tools:
```python
from fastapi import FastAPI
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import AgentFrameworkAgent, add_agent_framework_fastapi_endpoint
agent = ChatAgent(
name="my_agent",
instructions="You are a helpful assistant.",
chat_client=AzureOpenAIChatClient(
endpoint="https://your-resource.openai.azure.com/",
deployment_name="gpt-4o-mini",
),
)
wrapped_agent = AgentFrameworkAgent(
agent=agent,
require_confirmation=True, # Enable human-in-the-loop
)
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, wrapped_agent, "/")
```
The client receives tool approval request events and can send approval responses.
### 3. State Management
Share state between client and server:
```python
wrapped_agent = AgentFrameworkAgent(
agent=agent,
state_schema={
"location": {"type": "string"},
"preferences": {"type": "object"},
},
)
```
Events include `STATE_SNAPSHOT` and `STATE_DELTA` for bidirectional sync.
### 4. Predictive State Updates
Stream tool arguments as optimistic state updates:
```python
wrapped_agent = AgentFrameworkAgent(
agent=agent,
predict_state_config={
"location": {"tool": "get_weather", "tool_argument": "location"}
},
require_confirmation=False, # Auto-update without confirmation
)
```
State updates stream in real-time as the LLM generates tool arguments.
## Common Patterns
### Custom Server Configuration
```python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# Add CORS for web clients
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
add_agent_framework_fastapi_endpoint(app, agent, "/agent")
```
### Multiple Agents
```python
app = FastAPI()
weather_agent = ChatAgent(name="weather", ...)
finance_agent = ChatAgent(name="finance", ...)
add_agent_framework_fastapi_endpoint(app, weather_agent, "/weather")
add_agent_framework_fastapi_endpoint(app, finance_agent, "/finance")
```
### Custom Client Timeout
```python
async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream("POST", server_url, ...) as response:
async for line in response.aiter_lines():
# Process events
pass
```
### Error Handling
```python
try:
async for event in client.send_message(message):
if event.get("type") == "RUN_ERROR":
error_msg = event.get("message", "Unknown error")
print(f"Error: {error_msg}")
# Handle error appropriately
except httpx.HTTPError as e:
print(f"HTTP error: {e}")
except Exception as e:
print(f"Unexpected error: {e}")
```
### Conversation Continuity
The client automatically maintains `threadId` across requests:
```python
client = AGUIClient(server_url)
# First message
async for event in client.send_message("Hello"):
# Client captures threadId from RUN_STARTED
pass
# Second message - uses same threadId
async for event in client.send_message("Continue our conversation"):
# Conversation context is maintained
pass
```
## AG-UI Event Reference
### Core Events
| Event Type | Description | Key Fields |
|------------|-------------|------------|
| `RUN_STARTED` | Agent execution started | `threadId`, `runId` |
| `RUN_FINISHED` | Agent execution completed | `threadId`, `runId` |
| `RUN_ERROR` | Agent execution error | `message` |
### Text Message Events
| Event Type | Description | Key Fields |
|------------|-------------|------------|
| `TEXT_MESSAGE_START` | Start of agent text message | `messageId`, `role` |
| `TEXT_MESSAGE_CONTENT` | Streaming text content | `messageId`, `delta` |
| `TEXT_MESSAGE_END` | End of agent text message | `messageId` |
### Tool Events
| Event Type | Description | Key Fields |
|------------|-------------|------------|
| `TOOL_CALL_START` | Tool call initiated | `toolCallId`, `toolCallName` |
| `TOOL_CALL_ARGS` | Tool arguments streaming | `toolCallId`, `delta` |
| `TOOL_CALL_END` | Tool call complete | `toolCallId` |
| `TOOL_CALL_RESULT` | Tool execution result | `toolCallId`, `content` |
### State Events
| Event Type | Description | Key Fields |
|------------|-------------|------------|
| `STATE_SNAPSHOT` | Complete state | `snapshot` |
| `STATE_DELTA` | State changes (JSON Patch) | `delta` |
### Other Events
| Event Type | Description | Key Fields |
|------------|-------------|------------|
| `MESSAGES_SNAPSHOT` | Conversation history | `messages` |
| `CUSTOM` | Custom event data | `name`, `value` |
## Next Steps
Now that you understand the basics of AG-UI, you can:
- **Add Tools**: Create custom `@ai_function` tools for your domain
- **Web Integration**: Build React/Vue frontends using the AG-UI protocol
- **State Management**: Implement shared state for generative UI applications
- **Human-in-the-Loop**: Add approval workflows for sensitive operations
- **Deployment**: Deploy to Azure Container Apps or Azure App Service
- **Multi-Agent Systems**: Coordinate multiple specialized agents
- **Monitoring**: Add logging and OpenTelemetry for observability
## Additional Resources
- [AG-UI Examples](../agent_framework_ag_ui_examples/README.md): Complete working examples for all 7 features
- [Agent Framework Documentation](../../core/README.md): Learn more about creating agents
- [AG-UI Protocol Spec](https://docs.ag-ui.com/): Official protocol documentation
## Troubleshooting
### Connection Refused
+46 -96
View File
@@ -1,121 +1,71 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI client example."""
"""AG-UI client example using AGUIChatClient.
This example demonstrates how to use the AGUIChatClient to connect to
a remote AG-UI server and interact with it using the Agent Framework's
standard chat interface.
"""
import asyncio
import json
import os
from collections.abc import AsyncIterator
import httpx
class AGUIClient:
"""Simple AG-UI protocol client."""
def __init__(self, server_url: str):
"""Initialize the client.
Args:
server_url: The AG-UI server endpoint URL
"""
self.server_url = server_url
self.thread_id: str | None = None
async def send_message(self, message: str) -> AsyncIterator[dict]:
"""Send a message and stream the response.
Args:
message: The user message to send
Yields:
AG-UI events from the server
"""
# Prepare the request
request_data: dict[str, object] = {
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": message},
]
}
# Include thread_id if we have one (for conversation continuity)
if self.thread_id:
request_data["thread_id"] = self.thread_id
# Stream the response
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream(
"POST",
self.server_url,
json=request_data,
headers={"Accept": "text/event-stream"},
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
# Parse Server-Sent Events format
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
try:
event = json.loads(data)
yield event
# Capture thread_id from RUN_STARTED event
if event.get("type") == "RUN_STARTED" and not self.thread_id:
self.thread_id = event.get("threadId")
except json.JSONDecodeError:
continue
from agent_framework_ag_ui import AGUIChatClient
async def main():
"""Main client loop."""
"""Main client loop demonstrating AGUIChatClient usage."""
# Get server URL from environment or use default
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/")
print(f"Connecting to AG-UI server at: {server_url}\n")
print("Using AGUIChatClient with automatic thread management and Agent Framework integration.\n")
client = AGUIClient(server_url)
# Create client with context manager for automatic cleanup
async with AGUIChatClient(endpoint=server_url) as client:
thread_id: str | None = None
try:
while True:
# Get user input
message = input("\nUser (:q or quit to exit): ")
if not message.strip():
print("Request cannot be empty.")
continue
try:
while True:
# Get user input
message = input("\nUser (:q or quit to exit): ")
if not message.strip():
print("Request cannot be empty.")
continue
if message.lower() in (":q", "quit"):
break
if message.lower() in (":q", "quit"):
break
# Send message and display streaming response
print("\n", end="")
async for event in client.send_message(message):
event_type = event.get("type", "")
# Send message and stream the response
print("\nAssistant: ", end="", flush=True)
if event_type == "RUN_STARTED":
thread_id = event.get("threadId", "")
run_id = event.get("runId", "")
print(f"\033[93m[Run Started - Thread: {thread_id}, Run: {run_id}]\033[0m")
# Use metadata to maintain conversation continuity
metadata = {"thread_id": thread_id} if thread_id else None
elif event_type == "TEXT_MESSAGE_CONTENT":
# Stream text content in cyan
print(f"\033[96m{event.get('delta', '')}\033[0m", end="", flush=True)
async for update in client.get_streaming_response(message, metadata=metadata):
# Extract and display thread ID from first update
if not thread_id and update.additional_properties:
thread_id = update.additional_properties.get("thread_id")
if thread_id:
print(f"\n\033[93m[Thread: {thread_id}]\033[0m", end="", flush=True)
print("\nAssistant: ", end="", flush=True)
elif event_type == "RUN_FINISHED":
thread_id = event.get("threadId", "")
run_id = event.get("runId", "")
print(f"\n\033[92m[Run Finished - Thread: {thread_id}, Run: {run_id}]\033[0m")
# Display text content as it streams
from agent_framework import TextContent
elif event_type == "RUN_ERROR":
error_message = event.get("message", "Unknown error")
print(f"\n\033[91m[Run Error - Message: {error_message}]\033[0m")
for content in update.contents:
if isinstance(content, TextContent) and content.text:
print(f"\033[96m{content.text}\033[0m", end="", flush=True)
print()
# Display finish reason if present
if update.finish_reason:
print(f"\n\033[92m[Finished: {update.finish_reason}]\033[0m", end="", flush=True)
except KeyboardInterrupt:
print("\n\nExiting...")
except Exception as e:
print(f"\n\033[91mAn error occurred: {e}\033[0m")
print() # New line after response
except KeyboardInterrupt:
print("\n\nExiting...")
except Exception as e:
print(f"\n\033[91mAn error occurred: {e}\033[0m")
if __name__ == "__main__":
@@ -0,0 +1,235 @@
# Copyright (c) Microsoft. All rights reserved.
"""Advanced AG-UI client example with tools and features.
This example demonstrates advanced AGUIChatClient features including:
- Tool/function calling
- Non-streaming responses
- Multiple conversation turns
- Error handling
"""
import asyncio
import os
from agent_framework import ai_function
from agent_framework_ag_ui import AGUIChatClient
@ai_function
def get_weather(location: str) -> str:
"""Get the current weather for a location.
Args:
location: The city or location name
"""
# Simulate weather lookup
weather_data = {
"seattle": "Rainy, 55°F",
"san francisco": "Foggy, 62°F",
"new york": "Sunny, 68°F",
"london": "Cloudy, 52°F",
}
return weather_data.get(location.lower(), f"Weather data not available for {location}")
@ai_function
def calculate(a: float, b: float, operation: str) -> str:
"""Perform basic arithmetic operations.
Args:
a: First number
b: Second number
operation: Operation to perform (add, subtract, multiply, divide)
"""
try:
if operation == "add":
result = a + b
elif operation == "subtract":
result = a - b
elif operation == "multiply":
result = a * b
elif operation == "divide":
result = a / b
else:
return f"Unsupported operation: {operation}"
return f"The result is: {result}"
except Exception as e:
return f"Error calculating: {e}"
async def streaming_example(client: AGUIChatClient, thread_id: str | None = None):
"""Demonstrate streaming responses."""
print("\n" + "=" * 60)
print("STREAMING EXAMPLE")
print("=" * 60)
metadata = {"thread_id": thread_id} if thread_id else None
print("\nUser: Tell me a short joke\n")
print("Assistant: ", end="", flush=True)
async for update in client.get_streaming_response("Tell me a short joke", metadata=metadata):
if not thread_id and update.additional_properties:
thread_id = update.additional_properties.get("thread_id")
from agent_framework import TextContent
for content in update.contents:
if isinstance(content, TextContent) and content.text:
print(content.text, end="", flush=True)
print("\n")
return thread_id
async def non_streaming_example(client: AGUIChatClient, thread_id: str | None = None):
"""Demonstrate non-streaming responses."""
print("\n" + "=" * 60)
print("NON-STREAMING EXAMPLE")
print("=" * 60)
metadata = {"thread_id": thread_id} if thread_id else None
print("\nUser: What is 2 + 2?\n")
response = await client.get_response("What is 2 + 2?", metadata=metadata)
print(f"Assistant: {response.text}")
if response.additional_properties:
thread_id = response.additional_properties.get("thread_id")
print(f"\n[Thread: {thread_id}]")
return thread_id
async def tool_example(client: AGUIChatClient, thread_id: str | None = None):
"""Demonstrate sending tool definitions to the server.
IMPORTANT: When using AGUIChatClient directly (without ChatAgent wrapper):
- Tools are sent as DEFINITIONS only
- No automatic client-side execution (no function invocation middleware)
- Server must have matching tool implementations to execute them
For CLIENT-SIDE tool execution (like .NET AGUIClient sample):
- Use ChatAgent wrapper with tools
- See client_with_agent.py for the hybrid pattern
- ChatAgent middleware intercepts and executes client tools locally
- Server can have its own tools that execute server-side
- Both client and server tools work together in same conversation
This example sends tool definitions and assumes server-side execution.
"""
print("\n" + "=" * 60)
print("TOOL DEFINITION EXAMPLE")
print("=" * 60)
metadata = {"thread_id": thread_id} if thread_id else None
print("\nUser: What's the weather in Seattle?\n")
print("Sending tool definitions to server...")
print("(Server must be configured with matching tools to execute them)\n")
response = await client.get_response(
"What's the weather in Seattle?", tools=[get_weather, calculate], metadata=metadata
)
print(f"Assistant: {response.text}")
# Show tool calls if any
from agent_framework import FunctionCallContent
tool_called = False
for message in response.messages:
for content in message.contents:
if isinstance(content, FunctionCallContent):
print(f"\n[Tool Called: {content.name}]")
tool_called = True
if not tool_called:
print("\n[Note: No tools were called - server may not be configured for tool execution]")
if response.additional_properties:
thread_id = response.additional_properties.get("thread_id")
return thread_id
async def conversation_example(client: AGUIChatClient):
"""Demonstrate multi-turn conversation.
Note: Conversation continuity depends on the server maintaining thread state.
Some servers may require explicit message history to be sent with each request.
"""
print("\n" + "=" * 60)
print("MULTI-TURN CONVERSATION EXAMPLE")
print("=" * 60)
print("\nNote: This example uses thread_id for context. Server must support thread-based state.\n")
# First turn
print("User: My name is Alice\n")
response1 = await client.get_response("My name is Alice")
print(f"Assistant: {response1.text}")
thread_id = response1.additional_properties.get("thread_id")
print(f"\n[Thread: {thread_id}]")
# Second turn - using same thread
print("\nUser: What's my name?\n")
response2 = await client.get_response("What's my name?", metadata={"thread_id": thread_id})
print(f"Assistant: {response2.text}")
# Check if context was maintained
if "alice" not in response2.text.lower():
print("\n[Note: Server may not maintain thread context - consider using ChatAgent for history management]")
# Third turn
print("\nUser: Can you also tell me what 10 * 5 is?\n")
response3 = await client.get_response(
"Can you also tell me what 10 * 5 is?", metadata={"thread_id": thread_id}, tools=[calculate]
)
print(f"Assistant: {response3.text}")
async def main():
"""Run all examples."""
# Get server URL from environment or use default
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/")
print("=" * 60)
print("AG-UI Chat Client Advanced Examples")
print("=" * 60)
print(f"\nServer: {server_url}")
print("\nThese examples demonstrate various AGUIChatClient features:")
print(" 1. Streaming responses")
print(" 2. Non-streaming responses")
print(" 3. Tool/function calling")
print(" 4. Multi-turn conversations")
try:
async with AGUIChatClient(endpoint=server_url) as client:
# Run examples in sequence
thread_id = await streaming_example(client)
thread_id = await non_streaming_example(client, thread_id)
await tool_example(client, thread_id)
# Separate conversation with new thread
await conversation_example(client)
print("\n" + "=" * 60)
print("All examples completed successfully!")
print("=" * 60)
except ConnectionError as e:
print(f"\n\033[91mConnection Error: {e}\033[0m")
print("\nMake sure an AG-UI server is running at the specified endpoint.")
except Exception as e:
print(f"\n\033[91mError: {e}\033[0m")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,186 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example showing ChatAgent with AGUIChatClient for hybrid tool execution.
This demonstrates the HYBRID pattern matching .NET AGUIClient implementation:
1. AgentThread Pattern (like .NET):
- Create thread with agent.get_new_thread()
- Pass thread to agent.run_stream() on each turn
- Thread automatically maintains conversation history via message_store
2. Hybrid Tool Execution:
- AGUIChatClient has @use_function_invocation decorator
- Client-side tools (get_weather) can execute locally when server requests them
- Server may also have its own tools that execute server-side
- Both work together: server LLM decides which tool to call, decorator handles client execution
This matches .NET pattern: thread maintains state, tools execute on appropriate side.
"""
import asyncio
import logging
import os
from agent_framework import ChatAgent, FunctionCallContent, FunctionResultContent, TextContent, ai_function
from agent_framework_ag_ui import AGUIChatClient
# Enable debug logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
@ai_function(description="Get the current weather for a location.")
def get_weather(location: str) -> str:
"""Get the current weather for a location.
Args:
location: The city or location name
"""
print(f"[CLIENT] get_weather tool called with location: {location}")
weather_data = {
"seattle": "Rainy, 55°F",
"san francisco": "Foggy, 62°F",
"new york": "Sunny, 68°F",
"london": "Cloudy, 52°F",
}
result = weather_data.get(location.lower(), f"Weather data not available for {location}")
print(f"[CLIENT] get_weather returning: {result}")
return result
async def main():
"""Demonstrate ChatAgent + AGUIChatClient hybrid tool execution.
This matches the .NET pattern from Program.cs where:
- AIAgent agent = chatClient.CreateAIAgent(tools: [...])
- AgentThread thread = agent.GetNewThread()
- RunStreamingAsync(messages, thread)
Python equivalent:
- agent = ChatAgent(chat_client=AGUIChatClient(...), tools=[...])
- thread = agent.get_new_thread() # Creates thread with message_store
- agent.run_stream(message, thread=thread) # Thread accumulates history
"""
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/")
print("=" * 70)
print("ChatAgent + AGUIChatClient: Hybrid Tool Execution")
print("=" * 70)
print(f"\nServer: {server_url}")
print("\nThis example demonstrates:")
print(" 1. AgentThread maintains conversation state (like .NET)")
print(" 2. Client-side tools execute locally via @use_function_invocation")
print(" 3. Server may have additional tools that execute server-side")
print(" 4. HYBRID: Client and server tools work together simultaneously\n")
try:
# Create remote client in async context manager
async with AGUIChatClient(endpoint=server_url) as remote_client:
# Wrap in ChatAgent for conversation history management
agent = ChatAgent(
name="remote_assistant",
instructions="You are a helpful assistant. Remember user information across the conversation.",
chat_client=remote_client,
tools=[get_weather],
)
# Create a thread to maintain conversation state (like .NET AgentThread)
thread = agent.get_new_thread()
print("=" * 70)
print("CONVERSATION WITH HISTORY")
print("=" * 70)
# Turn 1: Introduce
print("\nUser: My name is Alice and I live in Seattle\n")
async for chunk in agent.run_stream("My name is Alice and I live in Seattle", thread=thread):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Turn 2: Ask about name (tests history)
print("User: What's my name?\n")
async for chunk in agent.run_stream("What's my name?", thread=thread):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Turn 3: Ask about location (tests history)
print("User: Where do I live?\n")
async for chunk in agent.run_stream("Where do I live?", thread=thread):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Turn 4: Test client-side tool (get_weather is client-side)
print("User: What's the weather forecast for today in Seattle?\n")
async for chunk in agent.run_stream("What's the weather forecast for today in Seattle?", thread=thread):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Turn 5: Test server-side tool (get_time_zone is server-side only)
print("User: What time zone is Seattle in?\n")
async for chunk in agent.run_stream("What time zone is Seattle in?", thread=thread):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Show thread state
if thread.message_store:
def _preview_for_message(m) -> str:
# Prefer plain text when present
if getattr(m, "text", ""):
t = m.text
return (t[:60] + "...") if len(t) > 60 else t
# Build from contents when no direct text
parts: list[str] = []
for c in getattr(m, "contents", []) or []:
if isinstance(c, FunctionCallContent):
args = c.arguments
if isinstance(args, dict):
try:
import json as _json
args_str = _json.dumps(args)
except Exception:
args_str = str(args)
else:
args_str = str(args or "{}")
parts.append(f"tool_call {c.name} {args_str}")
elif isinstance(c, FunctionResultContent):
parts.append(f"tool_result[{c.call_id}]: {str(c.result)[:40]}")
elif isinstance(c, TextContent):
if c.text:
parts.append(c.text)
else:
typename = getattr(c, "type", c.__class__.__name__)
parts.append(f"<{typename}>")
preview = " | ".join(parts) if parts else ""
return (preview[:60] + "...") if len(preview) > 60 else preview
messages = await thread.message_store.list_messages()
print(f"\n[THREAD STATE] {len(messages)} messages in thread's message_store")
for i, msg in enumerate(messages[-6:], 1): # Show last 6
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
text_preview = _preview_for_message(msg)
print(f" {i}. [{role}]: {text_preview}")
except ConnectionError as e:
print(f"\n\033[91mConnection Error: {e}\033[0m")
print("\nMake sure an AG-UI server is running at the specified endpoint.")
except Exception as e:
print(f"\n\033[91mError: {e}\033[0m")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())
@@ -1,18 +1,26 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI server example."""
"""AG-UI server example with server-side tools."""
import logging
import os
from agent_framework import ChatAgent
from agent_framework import ChatAgent, ai_function
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.azure import AzureOpenAIChatClient
from dotenv import load_dotenv
from fastapi import FastAPI
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
load_dotenv()
# Enable debug logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Read required configuration
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
deployment_name = os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME")
@@ -22,14 +30,43 @@ if not endpoint:
if not deployment_name:
raise ValueError("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME environment variable is required")
# Create the AI agent
# Server-side tool (executes on server)
@ai_function(description="Get the time zone for a location.")
def get_time_zone(location: str) -> str:
"""Get the time zone for a location.
Args:
location: The city or location name
"""
print(f"[SERVER] get_time_zone tool called with location: {location}")
timezone_data = {
"seattle": "Pacific Time (UTC-8)",
"san francisco": "Pacific Time (UTC-8)",
"new york": "Eastern Time (UTC-5)",
"london": "Greenwich Mean Time (UTC+0)",
}
result = timezone_data.get(location.lower(), f"Time zone data not available for {location}")
print(f"[SERVER] get_time_zone returning: {result}")
return result
# Create the AI agent with ONLY server-side tools
# IMPORTANT: Do NOT include tools that the client provides!
# In this example:
# - get_time_zone: SERVER-ONLY tool (only server has this)
# - get_weather: CLIENT-ONLY tool (client provides this, server should NOT include it)
# The client will send get_weather tool metadata so the LLM knows about it,
# and @use_function_invocation on AGUIChatClient will execute it client-side.
# This matches the .NET AG-UI hybrid execution pattern.
agent = ChatAgent(
name="AGUIAssistant",
instructions="You are a helpful assistant.",
instructions="You are a helpful assistant. Use get_weather for weather and get_time_zone for time zones.",
chat_client=AzureOpenAIChatClient(
endpoint=endpoint,
deployment_name=deployment_name,
),
tools=[get_time_zone], # ONLY server-side tools
)
# Create FastAPI app
@@ -41,4 +78,4 @@ add_agent_framework_fastapi_endpoint(app, agent, "/")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=5100)
uvicorn.run(app, host="127.0.0.1", port=5100, log_level="debug", access_log=True)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b251106.post1"
version = "1.0.0b251111"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
+317
View File
@@ -0,0 +1,317 @@
"""Tests for AGUIChatClient."""
import json
from agent_framework import ChatMessage, ChatOptions, FunctionCallContent, Role, ai_function
from agent_framework_ag_ui._client import AGUIChatClient, ServerFunctionCallContent
class TestAGUIChatClient:
"""Test suite for AGUIChatClient."""
async def test_client_initialization(self) -> None:
"""Test client initialization."""
client = AGUIChatClient(endpoint="http://localhost:8888/")
assert client._http_service is not None
assert client._http_service.endpoint.startswith("http://localhost:8888")
async def test_client_context_manager(self) -> None:
"""Test client as async context manager."""
async with AGUIChatClient(endpoint="http://localhost:8888/") as client:
assert client is not None
async def test_extract_state_from_messages_no_state(self) -> None:
"""Test state extraction when no state is present."""
client = AGUIChatClient(endpoint="http://localhost:8888/")
messages = [
ChatMessage(role="user", text="Hello"),
ChatMessage(role="assistant", text="Hi there"),
]
result_messages, state = client._extract_state_from_messages(messages)
assert result_messages == messages
assert state is None
async def test_extract_state_from_messages_with_state(self) -> None:
"""Test state extraction from last message."""
import base64
client = AGUIChatClient(endpoint="http://localhost:8888/")
state_data = {"key": "value", "count": 42}
state_json = json.dumps(state_data)
state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8")
from agent_framework import DataContent
messages = [
ChatMessage(role="user", text="Hello"),
ChatMessage(
role="user",
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
),
]
result_messages, state = client._extract_state_from_messages(messages)
assert len(result_messages) == 1
assert result_messages[0].text == "Hello"
assert state == state_data
async def test_extract_state_invalid_json(self) -> None:
"""Test state extraction with invalid JSON."""
import base64
client = AGUIChatClient(endpoint="http://localhost:8888/")
invalid_json = "not valid json"
state_b64 = base64.b64encode(invalid_json.encode("utf-8")).decode("utf-8")
from agent_framework import DataContent
messages = [
ChatMessage(
role="user",
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
),
]
result_messages, state = client._extract_state_from_messages(messages)
assert result_messages == messages
assert state is None
async def test_convert_messages_to_agui_format(self) -> None:
"""Test message conversion to AG-UI format."""
client = AGUIChatClient(endpoint="http://localhost:8888/")
messages = [
ChatMessage(role=Role.USER, text="What is the weather?"),
ChatMessage(role=Role.ASSISTANT, text="Let me check.", message_id="msg_123"),
]
agui_messages = client._convert_messages_to_agui_format(messages)
assert len(agui_messages) == 2
assert agui_messages[0]["role"] == "user"
assert agui_messages[0]["content"] == "What is the weather?"
assert agui_messages[1]["role"] == "assistant"
assert agui_messages[1]["content"] == "Let me check."
assert agui_messages[1]["id"] == "msg_123"
async def test_get_thread_id_from_metadata(self) -> None:
"""Test thread ID extraction from metadata."""
client = AGUIChatClient(endpoint="http://localhost:8888/")
chat_options = ChatOptions(metadata={"thread_id": "existing_thread_123"})
thread_id = client._get_thread_id(chat_options)
assert thread_id == "existing_thread_123"
async def test_get_thread_id_generation(self) -> None:
"""Test automatic thread ID generation."""
client = AGUIChatClient(endpoint="http://localhost:8888/")
chat_options = ChatOptions()
thread_id = client._get_thread_id(chat_options)
assert thread_id.startswith("thread_")
assert len(thread_id) > 7
async def test_get_streaming_response(self, monkeypatch) -> None:
"""Test streaming response method."""
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Hello"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": " world"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args, **kwargs):
for event in mock_events:
yield event
client = AGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client._http_service, "post_run", mock_post_run)
messages = [ChatMessage(role="user", text="Test message")]
chat_options = ChatOptions()
updates = []
async for update in client._inner_get_streaming_response(messages=messages, chat_options=chat_options):
updates.append(update)
assert len(updates) == 4
assert updates[0].additional_properties["thread_id"] == "thread_1"
assert updates[1].contents[0].text == "Hello"
assert updates[2].contents[0].text == " world"
async def test_get_response_non_streaming(self, monkeypatch) -> None:
"""Test non-streaming response method."""
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Complete response"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args, **kwargs):
for event in mock_events:
yield event
client = AGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client._http_service, "post_run", mock_post_run)
messages = [ChatMessage(role="user", text="Test message")]
chat_options = ChatOptions()
response = await client._inner_get_response(messages=messages, chat_options=chat_options)
assert response is not None
assert len(response.messages) > 0
assert "Complete response" in response.text
async def test_tool_handling(self, monkeypatch) -> None:
"""Test that client tool metadata is sent to server.
Client tool metadata (name, description, schema) is sent to server for planning.
When server requests a client function, @use_function_invocation decorator
intercepts and executes it locally. This matches .NET AG-UI implementation.
"""
from agent_framework import ai_function
@ai_function
def test_tool(param: str) -> str:
"""Test tool."""
return "result"
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args, **kwargs):
# Client tool metadata should be sent to server
tools = kwargs.get("tools")
assert tools is not None
assert len(tools) == 1
assert tools[0]["name"] == "test_tool"
assert tools[0]["description"] == "Test tool."
assert "parameters" in tools[0]
for event in mock_events:
yield event
client = AGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client._http_service, "post_run", mock_post_run)
messages = [ChatMessage(role="user", text="Test with tools")]
chat_options = ChatOptions(tools=[test_tool])
response = await client._inner_get_response(messages=messages, chat_options=chat_options)
assert response is not None
async def test_server_tool_calls_unwrapped_after_invocation(self, monkeypatch) -> None:
"""Ensure server-side tool calls are exposed as FunctionCallContent after processing."""
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "get_time_zone"},
{"type": "TOOL_CALL_ARGS", "toolCallId": "call_1", "delta": '{"location": "Seattle"}'},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args, **kwargs):
for event in mock_events:
yield event
client = AGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client._http_service, "post_run", mock_post_run)
messages = [ChatMessage(role="user", text="Test server tool execution")]
chat_options = ChatOptions()
updates = []
async for update in client.get_streaming_response(messages, chat_options=chat_options):
updates.append(update)
function_calls = [
content for update in updates for content in update.contents if isinstance(content, FunctionCallContent)
]
assert function_calls
assert function_calls[0].name == "get_time_zone"
assert not any(
isinstance(content, ServerFunctionCallContent) for update in updates for content in update.contents
)
async def test_server_tool_calls_not_executed_locally(self, monkeypatch) -> None:
"""Server tools should not trigger local function invocation even when client tools exist."""
@ai_function
def client_tool() -> str:
"""Client tool stub."""
return "client"
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "get_time_zone"},
{"type": "TOOL_CALL_ARGS", "toolCallId": "call_1", "delta": '{"location": "Seattle"}'},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args, **kwargs):
for event in mock_events:
yield event
async def fake_auto_invoke(*args, **kwargs):
function_call = kwargs.get("function_call_content") or args[0]
raise AssertionError(f"Unexpected local execution of server tool: {getattr(function_call, 'name', '?')}")
monkeypatch.setattr("agent_framework._tools._auto_invoke_function", fake_auto_invoke)
client = AGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client._http_service, "post_run", mock_post_run)
messages = [ChatMessage(role="user", text="Test server tool execution")]
chat_options = ChatOptions(tool_choice="auto", tools=[client_tool])
async for _ in client.get_streaming_response(messages, chat_options=chat_options):
pass
async def test_state_transmission(self, monkeypatch) -> None:
"""Test state is properly transmitted to server."""
import base64
state_data = {"user_id": "123", "session": "abc"}
state_json = json.dumps(state_data)
state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8")
from agent_framework import DataContent
messages = [
ChatMessage(role="user", text="Hello"),
ChatMessage(
role="user",
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
),
]
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args, **kwargs):
assert kwargs.get("state") == state_data
for event in mock_events:
yield event
client = AGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client._http_service, "post_run", mock_post_run)
chat_options = ChatOptions()
response = await client._inner_get_response(messages=messages, chat_options=chat_options)
assert response is not None
@@ -0,0 +1,287 @@
"""Tests for AG-UI event converter."""
from agent_framework import FinishReason, Role
from agent_framework_ag_ui._event_converters import AGUIEventConverter
class TestAGUIEventConverter:
"""Test suite for AGUIEventConverter."""
def test_run_started_event(self) -> None:
"""Test conversion of RUN_STARTED event."""
converter = AGUIEventConverter()
event = {
"type": "RUN_STARTED",
"threadId": "thread_123",
"runId": "run_456",
}
update = converter.convert_event(event)
assert update is not None
assert update.role == Role.ASSISTANT
assert update.additional_properties["thread_id"] == "thread_123"
assert update.additional_properties["run_id"] == "run_456"
assert converter.thread_id == "thread_123"
assert converter.run_id == "run_456"
def test_text_message_start_event(self) -> None:
"""Test conversion of TEXT_MESSAGE_START event."""
converter = AGUIEventConverter()
event = {
"type": "TEXT_MESSAGE_START",
"messageId": "msg_789",
}
update = converter.convert_event(event)
assert update is not None
assert update.role == Role.ASSISTANT
assert update.message_id == "msg_789"
assert converter.current_message_id == "msg_789"
def test_text_message_content_event(self) -> None:
"""Test conversion of TEXT_MESSAGE_CONTENT event."""
converter = AGUIEventConverter()
event = {
"type": "TEXT_MESSAGE_CONTENT",
"messageId": "msg_1",
"delta": "Hello",
}
update = converter.convert_event(event)
assert update is not None
assert update.role == Role.ASSISTANT
assert update.message_id == "msg_1"
assert len(update.contents) == 1
assert update.contents[0].text == "Hello"
def test_text_message_streaming(self) -> None:
"""Test streaming text across multiple TEXT_MESSAGE_CONTENT events."""
converter = AGUIEventConverter()
events = [
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Hello"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": " world"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "!"},
]
updates = [converter.convert_event(event) for event in events]
assert all(update is not None for update in updates)
assert all(update.message_id == "msg_1" for update in updates)
assert updates[0].contents[0].text == "Hello"
assert updates[1].contents[0].text == " world"
assert updates[2].contents[0].text == "!"
def test_text_message_end_event(self) -> None:
"""Test conversion of TEXT_MESSAGE_END event."""
converter = AGUIEventConverter()
event = {
"type": "TEXT_MESSAGE_END",
"messageId": "msg_1",
}
update = converter.convert_event(event)
assert update is None
def test_tool_call_start_event(self) -> None:
"""Test conversion of TOOL_CALL_START event."""
converter = AGUIEventConverter()
event = {
"type": "TOOL_CALL_START",
"toolCallId": "call_123",
"toolName": "get_weather",
}
update = converter.convert_event(event)
assert update is not None
assert update.role == Role.ASSISTANT
assert len(update.contents) == 1
assert update.contents[0].call_id == "call_123"
assert update.contents[0].name == "get_weather"
assert update.contents[0].arguments == ""
assert converter.current_tool_call_id == "call_123"
assert converter.current_tool_name == "get_weather"
def test_tool_call_start_with_tool_call_name(self) -> None:
"""Ensure TOOL_CALL_START with toolCallName still sets the tool name."""
converter = AGUIEventConverter()
event = {
"type": "TOOL_CALL_START",
"toolCallId": "call_abc",
"toolCallName": "get_weather",
}
update = converter.convert_event(event)
assert update is not None
assert update.contents[0].name == "get_weather"
assert converter.current_tool_name == "get_weather"
def test_tool_call_start_with_tool_call_name_snake_case(self) -> None:
"""Support tool_call_name snake_case field for backwards compatibility."""
converter = AGUIEventConverter()
event = {
"type": "TOOL_CALL_START",
"toolCallId": "call_snake",
"tool_call_name": "get_weather",
}
update = converter.convert_event(event)
assert update is not None
assert update.contents[0].name == "get_weather"
assert converter.current_tool_name == "get_weather"
def test_tool_call_args_streaming(self) -> None:
"""Test streaming tool arguments across multiple TOOL_CALL_ARGS events."""
converter = AGUIEventConverter()
converter.current_tool_call_id = "call_123"
converter.current_tool_name = "search"
events = [
{"type": "TOOL_CALL_ARGS", "delta": '{"query": "'},
{"type": "TOOL_CALL_ARGS", "delta": 'latest news"}'},
]
updates = [converter.convert_event(event) for event in events]
assert all(update is not None for update in updates)
assert updates[0].contents[0].arguments == '{"query": "'
assert updates[1].contents[0].arguments == 'latest news"}'
assert converter.accumulated_tool_args == '{"query": "latest news"}'
def test_tool_call_end_event(self) -> None:
"""Test conversion of TOOL_CALL_END event."""
converter = AGUIEventConverter()
converter.accumulated_tool_args = '{"location": "Seattle"}'
event = {
"type": "TOOL_CALL_END",
"toolCallId": "call_123",
}
update = converter.convert_event(event)
assert update is None
assert converter.accumulated_tool_args == ""
def test_tool_call_result_event(self) -> None:
"""Test conversion of TOOL_CALL_RESULT event."""
converter = AGUIEventConverter()
event = {
"type": "TOOL_CALL_RESULT",
"toolCallId": "call_123",
"result": {"temperature": 22, "condition": "sunny"},
}
update = converter.convert_event(event)
assert update is not None
assert update.role == Role.TOOL
assert len(update.contents) == 1
assert update.contents[0].call_id == "call_123"
assert update.contents[0].result == {"temperature": 22, "condition": "sunny"}
def test_run_finished_event(self) -> None:
"""Test conversion of RUN_FINISHED event."""
converter = AGUIEventConverter()
converter.thread_id = "thread_123"
converter.run_id = "run_456"
event = {
"type": "RUN_FINISHED",
"threadId": "thread_123",
"runId": "run_456",
}
update = converter.convert_event(event)
assert update is not None
assert update.role == Role.ASSISTANT
assert update.finish_reason == FinishReason.STOP
assert update.additional_properties["thread_id"] == "thread_123"
assert update.additional_properties["run_id"] == "run_456"
def test_run_error_event(self) -> None:
"""Test conversion of RUN_ERROR event."""
converter = AGUIEventConverter()
converter.thread_id = "thread_123"
converter.run_id = "run_456"
event = {
"type": "RUN_ERROR",
"message": "Connection timeout",
}
update = converter.convert_event(event)
assert update is not None
assert update.role == Role.ASSISTANT
assert update.finish_reason == FinishReason.CONTENT_FILTER
assert len(update.contents) == 1
assert update.contents[0].message == "Connection timeout"
assert update.contents[0].error_code == "RUN_ERROR"
def test_unknown_event_type(self) -> None:
"""Test handling of unknown event types."""
converter = AGUIEventConverter()
event = {
"type": "UNKNOWN_EVENT",
"data": "some data",
}
update = converter.convert_event(event)
assert update is None
def test_full_conversation_flow(self) -> None:
"""Test complete conversation flow with multiple event types."""
converter = AGUIEventConverter()
events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TEXT_MESSAGE_START", "messageId": "msg_1"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "I'll check"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": " the weather."},
{"type": "TEXT_MESSAGE_END", "messageId": "msg_1"},
{"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "get_weather"},
{"type": "TOOL_CALL_ARGS", "delta": '{"location": "Seattle"}'},
{"type": "TOOL_CALL_END", "toolCallId": "call_1"},
{"type": "TOOL_CALL_RESULT", "toolCallId": "call_1", "result": "Sunny, 72°F"},
{"type": "TEXT_MESSAGE_START", "messageId": "msg_2"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_2", "delta": "It's sunny!"},
{"type": "TEXT_MESSAGE_END", "messageId": "msg_2"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
updates = [converter.convert_event(event) for event in events]
non_none_updates = [u for u in updates if u is not None]
assert len(non_none_updates) == 10
assert converter.thread_id == "thread_1"
assert converter.run_id == "run_1"
def test_multiple_tool_calls(self) -> None:
"""Test handling multiple tool calls in sequence."""
converter = AGUIEventConverter()
events = [
{"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "search"},
{"type": "TOOL_CALL_ARGS", "delta": '{"query": "weather"}'},
{"type": "TOOL_CALL_END", "toolCallId": "call_1"},
{"type": "TOOL_CALL_START", "toolCallId": "call_2", "toolName": "fetch"},
{"type": "TOOL_CALL_ARGS", "delta": '{"url": "http://api.weather.com"}'},
{"type": "TOOL_CALL_END", "toolCallId": "call_2"},
]
updates = [converter.convert_event(event) for event in events]
non_none_updates = [u for u in updates if u is not None]
assert len(non_none_updates) == 4
assert non_none_updates[0].contents[0].name == "search"
assert non_none_updates[2].contents[0].name == "fetch"
@@ -0,0 +1,238 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for AGUIHttpService."""
import json
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from agent_framework_ag_ui._http_service import AGUIHttpService
@pytest.fixture
def mock_http_client():
"""Create a mock httpx.AsyncClient."""
client = AsyncMock(spec=httpx.AsyncClient)
return client
@pytest.fixture
def sample_events():
"""Sample AG-UI events for testing."""
return [
{"type": "RUN_STARTED", "threadId": "thread_123", "runId": "run_456"},
{"type": "TEXT_MESSAGE_START", "messageId": "msg_1", "role": "assistant"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Hello"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": " world"},
{"type": "TEXT_MESSAGE_END", "messageId": "msg_1"},
{"type": "RUN_FINISHED", "threadId": "thread_123", "runId": "run_456"},
]
def create_sse_response(events: list[dict]) -> str:
"""Create SSE formatted response from events."""
lines = []
for event in events:
lines.append(f"data: {json.dumps(event)}\n")
return "\n".join(lines)
async def test_http_service_initialization():
"""Test AGUIHttpService initialization."""
# Test with default client
service = AGUIHttpService("http://localhost:8888/")
assert service.endpoint == "http://localhost:8888"
assert service._owns_client is True
assert isinstance(service.http_client, httpx.AsyncClient)
await service.close()
# Test with custom client
custom_client = httpx.AsyncClient()
service = AGUIHttpService("http://localhost:8888/", http_client=custom_client)
assert service._owns_client is False
assert service.http_client is custom_client
# Shouldn't close the custom client
await service.close()
await custom_client.aclose()
async def test_http_service_strips_trailing_slash():
"""Test that endpoint trailing slash is stripped."""
service = AGUIHttpService("http://localhost:8888/")
assert service.endpoint == "http://localhost:8888"
await service.close()
async def test_post_run_successful_streaming(mock_http_client, sample_events):
"""Test successful streaming of events."""
# Create async generator for lines
async def mock_aiter_lines():
sse_data = create_sse_response(sample_events)
for line in sse_data.split("\n"):
if line:
yield line
# Create mock response
mock_response = AsyncMock()
mock_response.status_code = 200
# aiter_lines is called as a method, so it should return a new generator each time
mock_response.aiter_lines = mock_aiter_lines
# Setup mock streaming context manager
mock_stream_context = AsyncMock()
mock_stream_context.__aenter__.return_value = mock_response
mock_stream_context.__aexit__.return_value = None
mock_http_client.stream.return_value = mock_stream_context
service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client)
events = []
async for event in service.post_run(
thread_id="thread_123", run_id="run_456", messages=[{"role": "user", "content": "Hello"}]
):
events.append(event)
assert len(events) == len(sample_events)
assert events[0]["type"] == "RUN_STARTED"
assert events[-1]["type"] == "RUN_FINISHED"
# Verify request was made correctly
mock_http_client.stream.assert_called_once()
call_args = mock_http_client.stream.call_args
assert call_args.args[0] == "POST"
assert call_args.args[1] == "http://localhost:8888"
assert call_args.kwargs["headers"] == {"Accept": "text/event-stream"}
async def test_post_run_with_state_and_tools(mock_http_client):
"""Test posting run with state and tools."""
async def mock_aiter_lines():
return
yield # Make it an async generator
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.aiter_lines = mock_aiter_lines
mock_stream_context = AsyncMock()
mock_stream_context.__aenter__.return_value = mock_response
mock_stream_context.__aexit__.return_value = None
mock_http_client.stream.return_value = mock_stream_context
service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client)
state = {"user_context": {"name": "Alice"}}
tools = [{"type": "function", "function": {"name": "test_tool"}}]
async for _ in service.post_run(thread_id="thread_123", run_id="run_456", messages=[], state=state, tools=tools):
pass
# Verify state and tools were included in request
call_args = mock_http_client.stream.call_args
request_data = call_args.kwargs["json"]
assert request_data["state"] == state
assert request_data["tools"] == tools
async def test_post_run_http_error(mock_http_client):
"""Test handling of HTTP errors."""
mock_response = Mock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
def raise_http_error():
raise httpx.HTTPStatusError("Server error", request=Mock(), response=mock_response)
mock_response_async = AsyncMock()
mock_response_async.raise_for_status = raise_http_error
mock_stream_context = AsyncMock()
mock_stream_context.__aenter__.return_value = mock_response_async
mock_stream_context.__aexit__.return_value = None
mock_http_client.stream.return_value = mock_stream_context
service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client)
with pytest.raises(httpx.HTTPStatusError):
async for _ in service.post_run(thread_id="thread_123", run_id="run_456", messages=[]):
pass
async def test_post_run_invalid_json(mock_http_client):
"""Test handling of invalid JSON in SSE stream."""
invalid_sse = "data: {invalid json}\n\ndata: " + json.dumps({"type": "RUN_FINISHED"}) + "\n"
async def mock_aiter_lines():
for line in invalid_sse.split("\n"):
if line:
yield line
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.aiter_lines = mock_aiter_lines
mock_stream_context = AsyncMock()
mock_stream_context.__aenter__.return_value = mock_response
mock_stream_context.__aexit__.return_value = None
mock_http_client.stream.return_value = mock_stream_context
service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client)
events = []
async for event in service.post_run(thread_id="thread_123", run_id="run_456", messages=[]):
events.append(event)
# Should skip invalid JSON and continue with valid events
assert len(events) == 1
assert events[0]["type"] == "RUN_FINISHED"
async def test_context_manager():
"""Test context manager functionality."""
async with AGUIHttpService("http://localhost:8888/") as service:
assert service.http_client is not None
assert service._owns_client is True
# Client should be closed after exiting context
async def test_context_manager_with_external_client():
"""Test context manager doesn't close external client."""
external_client = httpx.AsyncClient()
async with AGUIHttpService("http://localhost:8888/", http_client=external_client) as service:
assert service.http_client is external_client
assert service._owns_client is False
# External client should still be open
# (caller's responsibility to close)
await external_client.aclose()
async def test_post_run_empty_response(mock_http_client):
"""Test handling of empty response stream."""
async def mock_aiter_lines():
return
yield # Make it an async generator
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.aiter_lines = mock_aiter_lines
mock_stream_context = AsyncMock()
mock_stream_context.__aenter__.return_value = mock_response
mock_stream_context.__aexit__.return_value = None
mock_http_client.stream.return_value = mock_stream_context
service = AGUIHttpService("http://localhost:8888/", http_client=mock_http_client)
events = []
async for event in service.post_run(thread_id="thread_123", run_id="run_456", messages=[]):
events.append(event)
assert len(events) == 0
@@ -63,10 +63,9 @@ def test_agui_tool_result_to_agent_framework():
assert isinstance(message.contents[0], TextContent)
assert message.contents[0].text == '{"accepted": true, "steps": []}'
assert hasattr(message, "metadata")
assert message.metadata is not None
assert message.metadata.get("is_tool_result") is True
assert message.metadata.get("tool_call_id") == "call_123"
assert message.additional_properties is not None
assert message.additional_properties.get("is_tool_result") is True
assert message.additional_properties.get("tool_call_id") == "call_123"
def test_agui_multiple_messages_to_agent_framework():
@@ -159,6 +158,36 @@ def test_agui_message_without_id():
assert messages[0].message_id is None
def test_agui_with_tool_calls_to_agent_framework():
"""Assistant message with tool_calls is converted to FunctionCallContent."""
agui_msg = {
"role": "assistant",
"content": "Calling tool",
"tool_calls": [
{
"id": "call-123",
"type": "function",
"function": {"name": "get_weather", "arguments": {"location": "Seattle"}},
}
],
"id": "msg-789",
}
messages = agui_messages_to_agent_framework([agui_msg])
assert len(messages) == 1
msg = messages[0]
assert msg.role == Role.ASSISTANT
assert msg.message_id == "msg-789"
# First content is text, second is the function call
assert isinstance(msg.contents[0], TextContent)
assert msg.contents[0].text == "Calling tool"
assert isinstance(msg.contents[1], FunctionCallContent)
assert msg.contents[1].call_id == "call-123"
assert msg.contents[1].name == "get_weather"
assert msg.contents[1].arguments == {"location": "Seattle"}
def test_agent_framework_to_agui_with_tool_calls():
"""Test converting Agent Framework message with tool calls to AG-UI."""
msg = ChatMessage(
@@ -198,13 +227,15 @@ def test_agent_framework_to_agui_multiple_text_contents():
def test_agent_framework_to_agui_no_message_id():
"""Test message without message_id."""
"""Test message without message_id - should auto-generate ID."""
msg = ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
assert "id" not in messages[0]
assert "id" in messages[0] # ID should be auto-generated
assert messages[0]["id"] # ID should not be empty
assert len(messages[0]["id"]) > 0 # ID should be a valid string
def test_agent_framework_to_agui_system_role():
@@ -0,0 +1,82 @@
"""Tests for AG-UI orchestrators."""
from collections.abc import AsyncGenerator
from types import SimpleNamespace
from typing import Any
from agent_framework import AgentRunResponseUpdate, TextContent, ai_function
from agent_framework._tools import FunctionInvocationConfiguration
from agent_framework_ag_ui._agent import AgentConfig
from agent_framework_ag_ui._orchestrators import DefaultOrchestrator, ExecutionContext
@ai_function
def server_tool() -> str:
"""Server-executable tool."""
return "server"
class DummyAgent:
"""Minimal agent stub to capture run_stream parameters."""
def __init__(self) -> None:
self.chat_options = SimpleNamespace(tools=[server_tool], response_format=None)
self.tools = [server_tool]
self.chat_client = SimpleNamespace(
function_invocation_configuration=FunctionInvocationConfiguration(),
)
self.seen_tools: list[Any] | None = None
async def run_stream(
self,
messages: list[Any],
*,
thread: Any,
tools: list[Any] | None = None,
) -> AsyncGenerator[AgentRunResponseUpdate, None]:
self.seen_tools = tools
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."""
agent = DummyAgent()
orchestrator = DefaultOrchestrator()
input_data = {
"messages": [
{
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}],
}
],
"tools": [
{
"name": "get_weather",
"description": "Client weather lookup.",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
}
],
}
context = ExecutionContext(
input_data=input_data,
agent=agent,
config=AgentConfig(),
)
events = []
async for event in orchestrator.run(context):
events.append(event)
assert agent.seen_tools is not None
tool_names = [getattr(tool, "name", "?") for tool in agent.seen_tools]
assert "server_tool" in tool_names
assert "get_weather" in tool_names
assert agent.chat_client.function_invocation_configuration.additional_tools
+106
View File
@@ -197,3 +197,109 @@ def test_make_json_safe_fallback():
result = make_json_safe(obj)
# Objects with __dict__ return their __dict__ dict
assert isinstance(result, dict)
def test_convert_tools_to_agui_format_with_ai_function():
"""Test converting AIFunction to AG-UI format."""
from agent_framework import ai_function
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
@ai_function
def test_func(param: str, count: int = 5) -> str:
"""Test function."""
return f"{param} {count}"
result = convert_tools_to_agui_format([test_func])
assert result is not None
assert len(result) == 1
assert result[0]["name"] == "test_func"
assert result[0]["description"] == "Test function."
assert "parameters" in result[0]
assert "properties" in result[0]["parameters"]
def test_convert_tools_to_agui_format_with_callable():
"""Test converting plain callable to AG-UI format."""
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
def plain_func(x: int) -> int:
"""A plain function."""
return x * 2
result = convert_tools_to_agui_format([plain_func])
assert result is not None
assert len(result) == 1
assert result[0]["name"] == "plain_func"
assert result[0]["description"] == "A plain function."
assert "parameters" in result[0]
def test_convert_tools_to_agui_format_with_dict():
"""Test converting dict tool to AG-UI format."""
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
tool_dict = {
"name": "custom_tool",
"description": "Custom tool",
"parameters": {"type": "object"},
}
result = convert_tools_to_agui_format([tool_dict])
assert result is not None
assert len(result) == 1
assert result[0] == tool_dict
def test_convert_tools_to_agui_format_with_none():
"""Test converting None tools."""
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
result = convert_tools_to_agui_format(None)
assert result is None
def test_convert_tools_to_agui_format_with_single_tool():
"""Test converting single tool (not in list)."""
from agent_framework import ai_function
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
@ai_function
def single_tool(arg: str) -> str:
"""Single tool."""
return arg
result = convert_tools_to_agui_format(single_tool)
assert result is not None
assert len(result) == 1
assert result[0]["name"] == "single_tool"
def test_convert_tools_to_agui_format_with_multiple_tools():
"""Test converting multiple tools."""
from agent_framework import ai_function
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
@ai_function
def tool1(x: int) -> int:
"""Tool 1."""
return x
@ai_function
def tool2(y: str) -> str:
"""Tool 2."""
return y
result = convert_tools_to_agui_format([tool1, tool2])
assert result is not None
assert len(result) == 2
assert result[0]["name"] == "tool1"
assert result[1]["name"] == "tool2"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251105"
version = "1.0.0b251111"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251105"
version = "1.0.0b251111"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251105"
version = "1.0.0b251111"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251105"
version = "1.0.0b251111"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -587,9 +587,11 @@ class ChatAgent(BaseAgent):
name: str | None = None,
description: str | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
conversation_id: str | None = None,
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
middleware: Middleware | list[Middleware] | None = None,
# chat option params
allow_multiple_tool_calls: bool | None = None,
conversation_id: str | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
@@ -630,15 +632,17 @@ class ChatAgent(BaseAgent):
description: A brief description of the agent's purpose.
chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol.
If not provided, the default in-memory store will be used.
conversation_id: The conversation ID for service-managed threads.
Cannot be used together with chat_message_store_factory.
context_providers: The collection of multiple context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
conversation_id: The conversation ID for service-managed threads.
Cannot be used together with chat_message_store_factory.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
model_id: The model_id to use for the agent.
This overrides the model_id set in the chat client if it contains one.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
@@ -687,7 +691,8 @@ class ChatAgent(BaseAgent):
self._local_mcp_tools = [tool for tool in normalized_tools if isinstance(tool, MCPTool)]
agent_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)]
self.chat_options = ChatOptions(
model_id=model_id,
model_id=model_id or (str(chat_client.model_id) if hasattr(chat_client, "model_id") else None),
allow_multiple_tool_calls=allow_multiple_tool_calls,
conversation_id=conversation_id,
frequency_penalty=frequency_penalty,
instructions=instructions,
@@ -758,6 +763,7 @@ class ChatAgent(BaseAgent):
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
allow_multiple_tool_calls: bool | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
@@ -793,6 +799,7 @@ class ChatAgent(BaseAgent):
Keyword Args:
thread: The thread to use for the agent.
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
@@ -844,6 +851,7 @@ class ChatAgent(BaseAgent):
co = run_chat_options & ChatOptions(
model_id=model_id,
conversation_id=thread.service_thread_id,
allow_multiple_tool_calls=allow_multiple_tool_calls,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
@@ -887,6 +895,7 @@ class ChatAgent(BaseAgent):
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
allow_multiple_tool_calls: bool | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
@@ -922,6 +931,7 @@ class ChatAgent(BaseAgent):
Keyword Args:
thread: The thread to use for the agent.
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
@@ -971,6 +981,7 @@ class ChatAgent(BaseAgent):
co = run_chat_options & ChatOptions(
conversation_id=thread.service_thread_id,
allow_multiple_tool_calls=allow_multiple_tool_calls,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
@@ -224,7 +224,7 @@ def _merge_chat_options(
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: list[ToolProtocol | dict[str, Any] | Callable[..., Any]] | None = None,
top_p: float | None = None,
user: str | None = None,
@@ -496,7 +496,7 @@ class BaseChatClient(SerializationMixin, ABC):
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
@@ -591,7 +591,7 @@ class BaseChatClient(SerializationMixin, ABC):
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
@@ -714,6 +714,8 @@ class BaseChatClient(SerializationMixin, ABC):
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
middleware: Middleware | list[Middleware] | None = None,
allow_multiple_tool_calls: bool | None = None,
conversation_id: str | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
@@ -751,6 +753,8 @@ class BaseChatClient(SerializationMixin, ABC):
If not provided, the default in-memory store will be used.
context_providers: Context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
allow_multiple_tool_calls: Whether to allow multiple tool calls per agent turn.
conversation_id: The conversation ID to associate with the agent's messages.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
@@ -801,6 +805,8 @@ class BaseChatClient(SerializationMixin, ABC):
chat_message_store_factory=chat_message_store_factory,
context_providers=context_providers,
middleware=middleware,
allow_multiple_tool_calls=allow_multiple_tool_calls,
conversation_id=conversation_id,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
+10 -3
View File
@@ -19,7 +19,7 @@ from mcp.client.websocket import websocket_client
from mcp.shared.context import RequestContext
from mcp.shared.exceptions import McpError
from mcp.shared.session import RequestResponder
from pydantic import BaseModel, create_model
from pydantic import BaseModel, Field, create_model
from ._tools import AIFunction, HostedMCPSpecificApproval
from ._types import ChatMessage, Contents, DataContent, Role, TextContent, UriContent
@@ -224,13 +224,20 @@ def _get_input_model_from_mcp_tool(tool: types.Tool) -> type[BaseModel]:
prop_details = json.loads(prop_details) if isinstance(prop_details, str) else prop_details
python_type = resolve_type(prop_details)
description = prop_details.get("description", "")
# Create field definition for create_model
if prop_name in required:
field_definitions[prop_name] = (python_type, ...)
field_definitions[prop_name] = (
(python_type, Field(description=description)) if description else (python_type, ...)
)
else:
default_value = prop_details.get("default", None)
field_definitions[prop_name] = (python_type, default_value)
field_definitions[prop_name] = (
(python_type, Field(default=default_value, description=description))
if description
else (python_type, default_value)
)
return create_model(f"{tool.name}_input", **field_definitions)
@@ -1525,6 +1525,12 @@ def _handle_function_calls_response(
prepped_messages = prepare_messages(messages)
response: "ChatResponse | None" = None
fcc_messages: "list[ChatMessage]" = []
# If tools are provided but tool_choice is not set, default to "auto" for function invocation
tools = _extract_tools(kwargs)
if tools and kwargs.get("tool_choice") is None:
kwargs["tool_choice"] = "auto"
for attempt_idx in range(config.max_iterations if config.enabled else 0):
fcc_todo = _collect_approval_responses(prepped_messages)
if fcc_todo:
@@ -1050,6 +1050,50 @@ class DataContent(BaseContent):
def has_top_level_media_type(self, top_level_media_type: Literal["application", "audio", "image", "text"]) -> bool:
return _has_top_level_media_type(self.media_type, top_level_media_type)
@staticmethod
def detect_image_format_from_base64(image_base64: str) -> str:
"""Detect image format from base64 data by examining the binary header.
Args:
image_base64: Base64 encoded image data
Returns:
Image format as string (png, jpeg, webp, gif) with png as fallback
"""
try:
# Constants for image format detection
# ~75 bytes of binary data should be enough to detect most image formats
FORMAT_DETECTION_BASE64_CHARS = 100
# Decode a small portion to detect format
decoded_data = base64.b64decode(image_base64[:FORMAT_DETECTION_BASE64_CHARS])
if decoded_data.startswith(b"\x89PNG"):
return "png"
if decoded_data.startswith(b"\xff\xd8\xff"):
return "jpeg"
if decoded_data.startswith(b"RIFF") and b"WEBP" in decoded_data[:12]:
return "webp"
if decoded_data.startswith(b"GIF87a") or decoded_data.startswith(b"GIF89a"):
return "gif"
return "png" # Default fallback
except Exception:
return "png" # Fallback if decoding fails
@classmethod
def create_data_uri_from_base64(cls, image_base64: str) -> tuple[str, str]:
"""Create a data URI and media type from base64 image data.
Args:
image_base64: Base64 encoded image data
Returns:
Tuple of (data_uri, media_type)
"""
format_type = cls.detect_image_format_from_base64(image_base64)
uri = f"data:image/{format_type};base64,{image_base64}"
media_type = f"image/{format_type}"
return uri, media_type
class UriContent(BaseContent):
"""Represents a URI content.
@@ -2,11 +2,14 @@
import logging
from dataclasses import dataclass
from typing import Any
from typing import Any, cast
from agent_framework import FunctionApprovalRequestContent, FunctionApprovalResponseContent
from .._agents import AgentProtocol, ChatAgent
from .._threads import AgentThread
from .._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._conversation_state import encode_chat_messages
from ._events import (
AgentRunEvent,
@@ -14,6 +17,7 @@ from ._events import (
)
from ._executor import Executor, handler
from ._message_utils import normalize_messages_input
from ._request_info_mixin import response_handler
from ._workflow_context import WorkflowContext
logger = logging.getLogger(__name__)
@@ -83,6 +87,8 @@ class AgentExecutor(Executor):
super().__init__(exec_id)
self._agent = agent
self._agent_thread = agent_thread or self._agent.get_new_thread()
self._pending_agent_requests: dict[str, FunctionApprovalRequestContent] = {}
self._pending_responses_to_agent: list[FunctionApprovalResponseContent] = []
self._output_response = output_response
self._cache: list[ChatMessage] = []
@@ -93,50 +99,6 @@ class AgentExecutor(Executor):
return [AgentRunResponse]
return []
async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse]) -> None:
"""Execute the underlying agent, emit events, and enqueue response.
Checks ctx.is_streaming() to determine whether to emit incremental AgentRunUpdateEvent
events (streaming mode) or a single AgentRunEvent (non-streaming mode).
"""
if ctx.is_streaming():
# Streaming mode: emit incremental updates
updates: list[AgentRunResponseUpdate] = []
async for update in self._agent.run_stream(
self._cache,
thread=self._agent_thread,
):
updates.append(update)
await ctx.add_event(AgentRunUpdateEvent(self.id, update))
if isinstance(self._agent, ChatAgent):
response_format = self._agent.chat_options.response_format
response = AgentRunResponse.from_agent_run_response_updates(
updates,
output_format_type=response_format,
)
else:
response = AgentRunResponse.from_agent_run_response_updates(updates)
else:
# Non-streaming mode: use run() and emit single event
response = await self._agent.run(
self._cache,
thread=self._agent_thread,
)
await ctx.add_event(AgentRunEvent(self.id, response))
if self._output_response:
await ctx.yield_output(response)
# Always construct a full conversation snapshot from inputs (cache)
# plus agent outputs (agent_run_response.messages). Do not mutate
# response.messages so AgentRunEvent remains faithful to the raw output.
full_conversation: list[ChatMessage] = list(self._cache) + list(response.messages)
agent_response = AgentExecutorResponse(self.id, response, full_conversation=full_conversation)
await ctx.send_message(agent_response)
self._cache.clear()
@handler
async def run(
self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse]
@@ -192,6 +154,31 @@ class AgentExecutor(Executor):
self._cache = normalize_messages_input(messages)
await self._run_agent_and_emit(ctx)
@response_handler
async def handle_user_input_response(
self,
original_request: FunctionApprovalRequestContent,
response: FunctionApprovalResponseContent,
ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse],
) -> None:
"""Handle user input responses for function approvals during agent execution.
This will hold the executor's execution until all pending user input requests are resolved.
Args:
original_request: The original function approval request sent by the agent.
response: The user's response to the function approval request.
ctx: The workflow context for emitting events and outputs.
"""
self._pending_responses_to_agent.append(response)
self._pending_agent_requests.pop(original_request.id, None)
if not self._pending_agent_requests:
# All pending requests have been resolved; resume agent execution
self._cache = normalize_messages_input(ChatMessage(role="user", contents=self._pending_responses_to_agent))
self._pending_responses_to_agent.clear()
await self._run_agent_and_emit(ctx)
async def snapshot_state(self) -> dict[str, Any]:
"""Capture current executor state for checkpointing.
@@ -226,6 +213,8 @@ class AgentExecutor(Executor):
return {
"cache": encode_chat_messages(self._cache),
"agent_thread": serialized_thread,
"pending_agent_requests": encode_checkpoint_value(self._pending_agent_requests),
"pending_responses_to_agent": encode_checkpoint_value(self._pending_responses_to_agent),
}
async def restore_state(self, state: dict[str, Any]) -> None:
@@ -258,7 +247,109 @@ class AgentExecutor(Executor):
else:
self._agent_thread = self._agent.get_new_thread()
pending_requests_payload = state.get("pending_agent_requests")
if pending_requests_payload:
self._pending_agent_requests = decode_checkpoint_value(pending_requests_payload)
pending_responses_payload = state.get("pending_responses_to_agent")
if pending_responses_payload:
self._pending_responses_to_agent = decode_checkpoint_value(pending_responses_payload)
def reset(self) -> None:
"""Reset the internal cache of the executor."""
logger.debug("AgentExecutor %s: Resetting cache", self.id)
self._cache.clear()
async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse]) -> None:
"""Execute the underlying agent, emit events, and enqueue response.
Checks ctx.is_streaming() to determine whether to emit incremental AgentRunUpdateEvent
events (streaming mode) or a single AgentRunEvent (non-streaming mode).
"""
if ctx.is_streaming():
# Streaming mode: emit incremental updates
response = await self._run_agent_streaming(cast(WorkflowContext, ctx))
else:
# Non-streaming mode: use run() and emit single event
response = await self._run_agent(cast(WorkflowContext, ctx))
if response is None:
# Agent did not complete (e.g., waiting for user input); do not emit response
logger.info("AgentExecutor %s: Agent did not complete, awaiting user input", self.id)
return
if self._output_response:
await ctx.yield_output(response)
# Always construct a full conversation snapshot from inputs (cache)
# plus agent outputs (agent_run_response.messages). Do not mutate
# response.messages so AgentRunEvent remains faithful to the raw output.
full_conversation: list[ChatMessage] = list(self._cache) + list(response.messages)
agent_response = AgentExecutorResponse(self.id, response, full_conversation=full_conversation)
await ctx.send_message(agent_response)
self._cache.clear()
async def _run_agent(self, ctx: WorkflowContext) -> AgentRunResponse | None:
"""Execute the underlying agent in non-streaming mode.
Args:
ctx: The workflow context for emitting events.
Returns:
The complete AgentRunResponse, or None if waiting for user input.
"""
response = await self._agent.run(
self._cache,
thread=self._agent_thread,
)
await ctx.add_event(AgentRunEvent(self.id, response))
# Handle any user input requests
if response.user_input_requests:
for user_input_request in response.user_input_requests:
self._pending_agent_requests[user_input_request.id] = user_input_request
await ctx.request_info(user_input_request, FunctionApprovalResponseContent)
return None
return response
async def _run_agent_streaming(self, ctx: WorkflowContext) -> AgentRunResponse | None:
"""Execute the underlying agent in streaming mode and collect the full response.
Args:
ctx: The workflow context for emitting events.
Returns:
The complete AgentRunResponse, or None if waiting for user input.
"""
updates: list[AgentRunResponseUpdate] = []
user_input_requests: list[FunctionApprovalRequestContent] = []
async for update in self._agent.run_stream(
self._cache,
thread=self._agent_thread,
):
updates.append(update)
await ctx.add_event(AgentRunUpdateEvent(self.id, update))
if update.user_input_requests:
user_input_requests.extend(update.user_input_requests)
# Build the final AgentRunResponse from the collected updates
if isinstance(self._agent, ChatAgent):
response_format = self._agent.chat_options.response_format
response = AgentRunResponse.from_agent_run_response_updates(
updates,
output_format_type=response_format,
)
else:
response = AgentRunResponse.from_agent_run_response_updates(updates)
# Handle any user input requests after the streaming completes
if user_input_requests:
for user_input_request in user_input_requests:
self._pending_agent_requests[user_input_request.id] = user_input_request
await ctx.request_info(user_input_request, FunctionApprovalResponseContent)
return None
return response
@@ -85,8 +85,8 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent:
# so we need to recombine them here to pass the complete tools list to the constructor.
# This makes sure MCP tools are preserved when cloning agents for handoff workflows.
all_tools = list(options.tools) if options.tools else []
if agent._local_mcp_tools:
all_tools.extend(agent._local_mcp_tools)
if agent._local_mcp_tools: # type: ignore
all_tools.extend(agent._local_mcp_tools) # type: ignore
return ChatAgent(
chat_client=agent.chat_client,
@@ -133,6 +133,14 @@ class _ConversationWithUserInput:
full_conversation: list[ChatMessage] = field(default_factory=lambda: []) # type: ignore[misc]
@dataclass
class _ConversationForUserInput:
"""Internal message from coordinator to gateway specifying which agent will receive the response."""
conversation: list[ChatMessage]
next_agent_id: str
class _AutoHandoffMiddleware(FunctionMiddleware):
"""Intercept handoff tool invocations and short-circuit execution with synthetic results."""
@@ -275,6 +283,7 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]],
id: str,
handoff_tool_targets: Mapping[str, str] | None = None,
return_to_previous: bool = False,
) -> None:
"""Create a coordinator that manages routing between specialists and the user."""
super().__init__(id)
@@ -284,6 +293,8 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
self._input_gateway_id = input_gateway_id
self._termination_condition = termination_condition
self._handoff_tool_targets = {k.lower(): v for k, v in (handoff_tool_targets or {}).items()}
self._return_to_previous = return_to_previous
self._current_agent_id: str | None = None # Track the current agent handling conversation
def _get_author_name(self) -> str:
"""Get the coordinator name for orchestrator-generated messages."""
@@ -293,7 +304,7 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
async def handle_agent_response(
self,
response: AgentExecutorResponse,
ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], list[ChatMessage]],
ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], list[ChatMessage] | _ConversationForUserInput],
) -> None:
"""Process an agent's response and determine whether to route, request input, or terminate."""
# Hydrate coordinator state (and detect new run) using checkpointable executor state
@@ -329,6 +340,9 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
# Check for handoff from ANY agent (starting agent or specialist)
target = self._resolve_specialist(response.agent_run_response, conversation)
if target is not None:
# Update current agent when handoff occurs
self._current_agent_id = target
logger.info(f"Handoff detected: {source} -> {target}. Routing control to specialist '{target}'.")
await self._persist_state(ctx)
# Clean tool-related content before sending to next agent
cleaned = clean_conversation_for_handoff(conversation)
@@ -340,10 +354,15 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
if not is_starting_agent and source not in self._specialist_ids:
raise RuntimeError(f"HandoffCoordinator received response from unknown executor '{source}'.")
# Update current agent when they respond without handoff
self._current_agent_id = source
logger.info(
f"Agent '{source}' responded without handoff. "
f"Requesting user input. Return-to-previous: {self._return_to_previous}"
)
await self._persist_state(ctx)
if await self._check_termination():
logger.info("Handoff workflow termination condition met. Ending conversation.")
# Clean the output conversation for display
cleaned_output = clean_conversation_for_handoff(conversation)
await ctx.yield_output(cleaned_output)
@@ -352,7 +371,13 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
# Clean conversation before sending to gateway for user input request
# This removes tool messages that shouldn't be shown to users
cleaned_for_display = clean_conversation_for_handoff(conversation)
await ctx.send_message(cleaned_for_display, target_id=self._input_gateway_id)
# The awaiting_agent_id is the agent that just responded and is awaiting user input
# This is the source of the current response
next_agent_id = source
message_to_gateway = _ConversationForUserInput(conversation=cleaned_for_display, next_agent_id=next_agent_id)
await ctx.send_message(message_to_gateway, target_id=self._input_gateway_id) # type: ignore[arg-type]
@handler
async def handle_user_input(
@@ -367,14 +392,26 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
# Check termination before sending to agent
if await self._check_termination():
logger.info("Handoff workflow termination condition met. Ending conversation.")
await ctx.yield_output(list(self._conversation))
return
# Clean before sending to starting agent
# Determine routing target based on return-to-previous setting
target_agent_id = self._starting_agent_id
if self._return_to_previous and self._current_agent_id:
# Route back to the current agent that's handling the conversation
target_agent_id = self._current_agent_id
logger.info(
f"Return-to-previous enabled: routing user input to current agent '{target_agent_id}' "
f"(bypassing coordinator '{self._starting_agent_id}')"
)
else:
logger.info(f"Routing user input to coordinator '{target_agent_id}'")
# Note: Stack is only used for specialist-to-specialist handoffs, not user input routing
# Clean before sending to target agent
cleaned = clean_conversation_for_handoff(self._conversation)
request = AgentExecutorRequest(messages=cleaned, should_respond=True)
await ctx.send_message(request, target_id=self._starting_agent_id)
await ctx.send_message(request, target_id=target_agent_id)
def _resolve_specialist(self, agent_response: AgentRunResponse, conversation: list[ChatMessage]) -> str | None:
"""Resolve the specialist executor id requested by the agent response, if any."""
@@ -444,22 +481,27 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
def _snapshot_pattern_metadata(self) -> dict[str, Any]:
"""Serialize pattern-specific state.
Handoff has no additional metadata beyond base conversation state.
Includes the current agent for return-to-previous routing.
Returns:
Empty dict (no pattern-specific state)
Dict containing current agent if return-to-previous is enabled
"""
if self._return_to_previous:
return {
"current_agent_id": self._current_agent_id,
}
return {}
def _restore_pattern_metadata(self, metadata: dict[str, Any]) -> None:
"""Restore pattern-specific state.
Handoff has no additional metadata beyond base conversation state.
Restores the current agent for return-to-previous routing.
Args:
metadata: Pattern-specific state dict (ignored)
metadata: Pattern-specific state dict
"""
pass
if self._return_to_previous and "current_agent_id" in metadata:
self._current_agent_id = metadata["current_agent_id"]
def _restore_conversation_from_state(self, state: Mapping[str, Any]) -> list[ChatMessage]:
"""Rehydrate the coordinator's conversation history from checkpointed state.
@@ -507,8 +549,21 @@ class _UserInputGateway(Executor):
self._prompt = prompt or "Provide your next input for the conversation."
@handler
async def request_input(self, conversation: list[ChatMessage], ctx: WorkflowContext) -> None:
async def request_input(self, message: _ConversationForUserInput, ctx: WorkflowContext) -> None:
"""Emit a `HandoffUserInputRequest` capturing the conversation snapshot."""
if not message.conversation:
raise ValueError("Handoff workflow requires non-empty conversation before requesting user input.")
request = HandoffUserInputRequest(
conversation=list(message.conversation),
awaiting_agent_id=message.next_agent_id,
prompt=self._prompt,
source_executor_id=self.id,
)
await ctx.request_info(request, object)
@handler
async def request_input_legacy(self, conversation: list[ChatMessage], ctx: WorkflowContext) -> None:
"""Legacy handler for backward compatibility - emit user input request with starting agent."""
if not conversation:
raise ValueError("Handoff workflow requires non-empty conversation before requesting user input.")
request = HandoffUserInputRequest(
@@ -558,7 +613,7 @@ def _as_user_messages(payload: Any) -> list[ChatMessage]:
def _default_termination_condition(conversation: list[ChatMessage]) -> bool:
"""Default termination: stop after 10 user messages to prevent infinite loops."""
"""Default termination: stop after 10 user messages."""
user_message_count = sum(1 for msg in conversation if msg.role == Role.USER)
return user_message_count >= 10
@@ -743,6 +798,7 @@ class HandoffBuilder:
)
self._auto_register_handoff_tools: bool = True
self._handoff_config: dict[str, list[str]] = {} # Maps agent_id -> [target_agent_ids]
self._return_to_previous: bool = False
if participants:
self.participants(participants)
@@ -1198,6 +1254,77 @@ class HandoffBuilder:
self._termination_condition = condition
return self
def enable_return_to_previous(self, enabled: bool = True) -> "HandoffBuilder":
"""Enable direct return to the current agent after user input, bypassing the coordinator.
When enabled, after a specialist responds without requesting another handoff, user input
routes directly back to that same specialist instead of always routing back to the
coordinator agent for re-evaluation.
This is useful when a specialist needs multiple turns with the user to gather information
or resolve an issue, avoiding unnecessary coordinator involvement while maintaining context.
Flow Comparison:
**Default (disabled):**
User -> Coordinator -> Specialist -> User -> Coordinator -> Specialist -> ...
**With return_to_previous (enabled):**
User -> Coordinator -> Specialist -> User -> Specialist -> ...
Args:
enabled: Whether to enable return-to-previous routing. Default is True.
Returns:
Self for method chaining.
Example:
.. code-block:: python
workflow = (
HandoffBuilder(participants=[triage, technical_support, billing])
.set_coordinator("triage")
.add_handoff(triage, [technical_support, billing])
.enable_return_to_previous() # Enable direct return routing
.build()
)
# Flow: User asks question
# -> Triage routes to Technical Support
# -> Technical Support asks clarifying question
# -> User provides more info
# -> Routes back to Technical Support (not Triage)
# -> Technical Support continues helping
Multi-tier handoff example:
.. code-block:: python
workflow = (
HandoffBuilder(participants=[triage, specialist_a, specialist_b])
.set_coordinator("triage")
.add_handoff(triage, [specialist_a, specialist_b])
.add_handoff(specialist_a, specialist_b)
.enable_return_to_previous()
.build()
)
# Flow: User asks question
# -> Triage routes to Specialist A
# -> Specialist A hands off to Specialist B
# -> Specialist B asks clarifying question
# -> User provides more info
# -> Routes back to Specialist B (who is currently handling the conversation)
Note:
This feature routes to whichever agent most recently responded, whether that's
the coordinator or a specialist. The conversation continues with that agent until
they either hand off to another agent or the termination condition is met.
"""
self._return_to_previous = enabled
return self
def build(self) -> Workflow:
"""Construct the final Workflow instance from the configured builder.
@@ -1326,6 +1453,7 @@ class HandoffBuilder:
termination_condition=self._termination_condition,
id="handoff-coordinator",
handoff_tool_targets=handoff_tool_targets,
return_to_previous=self._return_to_previous,
)
wiring = _GroupChatConfig(
@@ -0,0 +1,35 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
from typing import Any
PACKAGE_NAME = "agent_framework_ag_ui"
PACKAGE_EXTRA = "ag-ui"
_IMPORTS = [
"__version__",
"AgentFrameworkAgent",
"add_agent_framework_fastapi_endpoint",
"AGUIChatClient",
"AGUIEventConverter",
"AGUIHttpService",
"ConfirmationStrategy",
"DefaultConfirmationStrategy",
"TaskPlannerConfirmationStrategy",
"RecipeConfirmationStrategy",
"DocumentWriterConfirmationStrategy",
]
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
try:
return getattr(importlib.import_module(PACKAGE_NAME), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`"
) from exc
raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.")
def __dir__() -> list[str]:
return _IMPORTS
@@ -0,0 +1,29 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_ag_ui import (
AgentFrameworkAgent,
AGUIChatClient,
AGUIEventConverter,
AGUIHttpService,
ConfirmationStrategy,
DefaultConfirmationStrategy,
DocumentWriterConfirmationStrategy,
RecipeConfirmationStrategy,
TaskPlannerConfirmationStrategy,
__version__,
add_agent_framework_fastapi_endpoint,
)
__all__ = [
"AGUIChatClient",
"AGUIEventConverter",
"AGUIHttpService",
"AgentFrameworkAgent",
"ConfirmationStrategy",
"DefaultConfirmationStrategy",
"DocumentWriterConfirmationStrategy",
"RecipeConfirmationStrategy",
"TaskPlannerConfirmationStrategy",
"__version__",
"add_agent_framework_fastapi_endpoint",
]
@@ -846,6 +846,7 @@ def _trace_get_response(
kwargs.get("model_id")
or (chat_options.model_id if (chat_options := kwargs.get("chat_options")) else None)
or getattr(self, "model_id", None)
or "unknown"
)
service_url = str(
service_url_func()
@@ -933,6 +934,7 @@ def _trace_get_streaming_response(
kwargs.get("model_id")
or (chat_options.model_id if (chat_options := kwargs.get("chat_options")) else None)
or getattr(self, "model_id", None)
or "unknown"
)
service_url = str(
service_url_func()
@@ -1324,7 +1326,10 @@ def _get_span(
attributes: dict[str, Any],
span_name_attribute: str,
) -> Generator["trace.Span", Any, Any]:
"""Start a span for a agent run."""
"""Start a span for a agent run.
Note: `attributes` must contain the `span_name_attribute` key.
"""
span = get_tracer().start_span(f"{attributes[OtelAttr.OPERATION]} {attributes[span_name_attribute]}")
span.set_attributes(attributes)
with trace.use_span(
@@ -1353,7 +1358,8 @@ def _get_span_attributes(**kwargs: Any) -> dict[str, Any]:
attributes[SpanAttributes.LLM_SYSTEM] = system_name
if provider_name := kwargs.get("provider_name"):
attributes[OtelAttr.PROVIDER_NAME] = provider_name
attributes[SpanAttributes.LLM_REQUEST_MODEL] = kwargs.get("model", "unknown")
if model_id := kwargs.get("model", chat_options.model_id):
attributes[SpanAttributes.LLM_REQUEST_MODEL] = model_id
if service_url := kwargs.get("service_url"):
attributes[OtelAttr.ADDRESS] = service_url
if conversation_id := kwargs.get("conversation_id", chat_options.conversation_id):
@@ -276,6 +276,14 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
# Map the parameter name and remove the old one
mapped_tool[api_param] = mapped_tool.pop(user_param)
# Validate partial_images parameter for streaming image generation
# OpenAI API requires partial_images to be between 0-3 (inclusive) for image_generation tool
# Reference: https://platform.openai.com/docs/api-reference/responses/create#responses_create-tools-image_generation_tool-partial_images
if "partial_images" in mapped_tool:
partial_images = mapped_tool["partial_images"]
if not isinstance(partial_images, int) or partial_images < 0 or partial_images > 3:
raise ValueError("partial_images must be an integer between 0 and 3 (inclusive).")
response_tools.append(mapped_tool)
else:
response_tools.append(tool_dict)
@@ -707,29 +715,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
uri = item.result
media_type = None
if not uri.startswith("data:"):
# Raw base64 string - convert to proper data URI format
# Detect format from base64 data
import base64
try:
# Decode a small portion to detect format
decoded_data = base64.b64decode(uri[:100]) # First ~75 bytes should be enough
if decoded_data.startswith(b"\x89PNG"):
format_type = "png"
elif decoded_data.startswith(b"\xff\xd8\xff"):
format_type = "jpeg"
elif decoded_data.startswith(b"RIFF") and b"WEBP" in decoded_data[:12]:
format_type = "webp"
elif decoded_data.startswith(b"GIF87a") or decoded_data.startswith(b"GIF89a"):
format_type = "gif"
else:
# Default to png if format cannot be detected
format_type = "png"
except Exception:
# Fallback to png if decoding fails
format_type = "png"
uri = f"data:image/{format_type};base64,{uri}"
media_type = f"image/{format_type}"
# Raw base64 string - convert to proper data URI format using helper
uri, media_type = DataContent.create_data_uri_from_base64(uri)
else:
# Parse media type from existing data URI
try:
@@ -945,6 +932,25 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
raw_representation=event,
)
)
case "response.image_generation_call.partial_image":
# Handle streaming partial image generation
image_base64 = event.partial_image_b64
partial_index = event.partial_image_index
# Use helper function to create data URI from base64
uri, media_type = DataContent.create_data_uri_from_base64(image_base64)
contents.append(
DataContent(
uri=uri,
media_type=media_type,
additional_properties={
"partial_image_index": partial_index,
"is_partial_image": True,
},
raw_representation=event,
)
)
case _:
logger.debug("Unparsed event of type: %s: %s", event.type, event)
+5 -4
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251105"
version = "1.0.0b251111"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -42,13 +42,14 @@ dependencies = [
[project.optional-dependencies]
all = [
"agent-framework-a2a",
"agent-framework-ag-ui",
"agent-framework-anthropic",
"agent-framework-azure-ai",
"agent-framework-copilotstudio",
"agent-framework-mem0",
"agent-framework-redis",
"agent-framework-devui",
"agent-framework-mem0",
"agent-framework-purview",
"agent-framework-anthropic",
"agent-framework-redis",
]
[tool.uv]
@@ -279,6 +279,45 @@ async def test_chat_client_streaming_observability(
assert span.attributes[OtelAttr.OUTPUT_MESSAGES] is not None
async def test_chat_client_without_model_id_observability(mock_chat_client, span_exporter: InMemorySpanExporter):
"""Test telemetry shouldn't fail when the model_id is not provided for unknown reason."""
client = use_observability(mock_chat_client)()
messages = [ChatMessage(role=Role.USER, text="Test")]
span_exporter.clear()
response = await client.get_response(messages=messages)
assert response is not None
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "chat unknown"
assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.CHAT_COMPLETION_OPERATION
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown"
async def test_chat_client_streaming_without_model_id_observability(
mock_chat_client, span_exporter: InMemorySpanExporter
):
"""Test streaming telemetry shouldn't fail when the model_id is not provided for unknown reason."""
client = use_observability(mock_chat_client)()
messages = [ChatMessage(role=Role.USER, text="Test")]
span_exporter.clear()
# Collect all yielded updates
updates = []
async for update in client.get_streaming_response(messages=messages):
updates.append(update)
# Verify we got the expected updates, this shouldn't be dependent on otel
assert len(updates) == 2
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "chat unknown"
assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.CHAT_COMPLETION_OPERATION
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown"
def test_prepend_user_agent_with_none_value():
"""Test prepend user agent with None value in headers."""
headers = {"User-Agent": None}
@@ -368,6 +407,7 @@ def mock_chat_agent():
self.name = "test_agent"
self.display_name = "Test Agent"
self.description = "Test agent description"
self.chat_options = ChatOptions(model_id="TestModel")
async def run(self, messages=None, *, thread=None, **kwargs):
return AgentRunResponse(
@@ -405,7 +445,7 @@ async def test_agent_instrumentation_enabled(
assert span.attributes[OtelAttr.AGENT_ID] == "test_agent_id"
assert span.attributes[OtelAttr.AGENT_NAME] == "Test Agent"
assert span.attributes[OtelAttr.AGENT_DESCRIPTION] == "Test agent description"
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown"
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "TestModel"
assert span.attributes[OtelAttr.INPUT_TOKENS] == 15
assert span.attributes[OtelAttr.OUTPUT_TOKENS] == 25
if enable_sensitive_data:
@@ -433,7 +473,7 @@ async def test_agent_streaming_response_with_diagnostics_enabled_via_decorator(
assert span.attributes[OtelAttr.AGENT_ID] == "test_agent_id"
assert span.attributes[OtelAttr.AGENT_NAME] == "Test Agent"
assert span.attributes[OtelAttr.AGENT_DESCRIPTION] == "Test agent description"
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown"
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "TestModel"
if enable_sensitive_data:
assert span.attributes.get(OtelAttr.OUTPUT_MESSAGES) is not None # Streaming, so no usage yet
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
import base64
from collections.abc import AsyncIterable
from typing import Any
@@ -166,6 +167,57 @@ def test_data_content_empty():
DataContent(uri="")
def test_data_content_detect_image_format_from_base64():
"""Test the detect_image_format_from_base64 static method."""
# Test each supported format
png_data = b"\x89PNG\r\n\x1a\n" + b"fake_data"
assert DataContent.detect_image_format_from_base64(base64.b64encode(png_data).decode()) == "png"
jpeg_data = b"\xff\xd8\xff\xe0" + b"fake_data"
assert DataContent.detect_image_format_from_base64(base64.b64encode(jpeg_data).decode()) == "jpeg"
webp_data = b"RIFF" + b"1234" + b"WEBP" + b"fake_data"
assert DataContent.detect_image_format_from_base64(base64.b64encode(webp_data).decode()) == "webp"
gif_data = b"GIF89a" + b"fake_data"
assert DataContent.detect_image_format_from_base64(base64.b64encode(gif_data).decode()) == "gif"
# Test fallback behavior
unknown_data = b"UNKNOWN_FORMAT"
assert DataContent.detect_image_format_from_base64(base64.b64encode(unknown_data).decode()) == "png"
# Test error handling
assert DataContent.detect_image_format_from_base64("invalid_base64!") == "png"
assert DataContent.detect_image_format_from_base64("") == "png"
def test_data_content_create_data_uri_from_base64():
"""Test the create_data_uri_from_base64 class method."""
# Test with PNG data
png_data = b"\x89PNG\r\n\x1a\n" + b"fake_data"
png_base64 = base64.b64encode(png_data).decode()
uri, media_type = DataContent.create_data_uri_from_base64(png_base64)
assert uri == f"data:image/png;base64,{png_base64}"
assert media_type == "image/png"
# Test with different format
jpeg_data = b"\xff\xd8\xff\xe0" + b"fake_data"
jpeg_base64 = base64.b64encode(jpeg_data).decode()
uri, media_type = DataContent.create_data_uri_from_base64(jpeg_base64)
assert uri == f"data:image/jpeg;base64,{jpeg_base64}"
assert media_type == "image/jpeg"
# Test fallback for unknown format
unknown_data = b"UNKNOWN_FORMAT"
unknown_base64 = base64.b64encode(unknown_data).decode()
uri, media_type = DataContent.create_data_uri_from_base64(unknown_base64)
assert uri == f"data:image/png;base64,{unknown_base64}"
assert media_type == "image/png"
# region UriContent
File diff suppressed because it is too large Load Diff
@@ -111,6 +111,10 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
chat_store_state = thread_state["chat_message_store_state"] # type: ignore[index]
assert "messages" in chat_store_state, "Message store state should include messages"
# Verify checkpoint contains pending requests from agents and responses to be sent
assert "pending_agent_requests" in executor_state
assert "pending_responses_to_agent" in executor_state
# Create a new agent and executor for restoration
# This simulates starting from a fresh state and restoring from checkpoint
restored_agent = _CountingAgent(id="test_agent", name="TestAgent")
@@ -5,19 +5,32 @@
from collections.abc import AsyncIterable
from typing import Any
from typing_extensions import Never
from agent_framework import (
AgentExecutor,
AgentExecutorResponse,
AgentRunResponse,
AgentRunResponseUpdate,
AgentRunUpdateEvent,
AgentThread,
BaseAgent,
ChatAgent,
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
RequestInfoEvent,
Role,
TextContent,
WorkflowBuilder,
WorkflowContext,
WorkflowOutputEvent,
ai_function,
executor,
use_function_invocation,
)
@@ -120,3 +133,235 @@ async def test_agent_executor_emits_tool_calls_in_streaming_mode() -> None:
assert events[3].data is not None
assert isinstance(events[3].data.contents[0], TextContent)
assert "sunny" in events[3].data.contents[0].text
@ai_function(approval_mode="always_require")
def mock_tool_requiring_approval(query: str) -> str:
"""Mock tool that requires approval before execution."""
return f"Executed tool with query: {query}"
@use_function_invocation
class MockChatClient:
"""Simple implementation of a chat client."""
def __init__(self, parallel_request: bool = False) -> None:
self.additional_properties: dict[str, Any] = {}
self._iteration: int = 0
self._parallel_request: bool = parallel_request
async def get_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
**kwargs: Any,
) -> ChatResponse:
if self._iteration == 0:
if self._parallel_request:
response = ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
FunctionCallContent(
call_id="2", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
],
)
)
else:
response = ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
)
],
)
)
else:
response = ChatResponse(messages=ChatMessage(role="assistant", text="Tool executed successfully."))
self._iteration += 1
return response
async def get_streaming_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
if self._iteration == 0:
if self._parallel_request:
yield ChatResponseUpdate(
contents=[
FunctionCallContent(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
FunctionCallContent(
call_id="2", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
],
role="assistant",
)
else:
yield ChatResponseUpdate(
contents=[
FunctionCallContent(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
)
],
role="assistant",
)
else:
yield ChatResponseUpdate(text=TextContent(text="Tool executed "), role="assistant")
yield ChatResponseUpdate(contents=[TextContent(text="successfully.")], role="assistant")
self._iteration += 1
@executor(id="test_executor")
async def test_executor(agent_executor_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output(agent_executor_response.agent_run_response.text)
async def test_agent_executor_tool_call_with_approval() -> None:
"""Test that AgentExecutor handles tool calls requiring approval."""
# Arrange
agent = ChatAgent(
chat_client=MockChatClient(),
name="ApprovalAgent",
tools=[mock_tool_requiring_approval],
)
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build()
# Act
events = await workflow.run("Invoke tool requiring approval")
# Assert
assert len(events.get_request_info_events()) == 1
approval_request = events.get_request_info_events()[0]
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
events = await workflow.send_responses({approval_request.request_id: approval_request.data.create_response(True)})
# Assert
final_response = events.get_outputs()
assert len(final_response) == 1
assert final_response[0] == "Tool executed successfully."
async def test_agent_executor_tool_call_with_approval_streaming() -> None:
"""Test that AgentExecutor handles tool calls requiring approval in streaming mode."""
# Arrange
agent = ChatAgent(
chat_client=MockChatClient(),
name="ApprovalAgent",
tools=[mock_tool_requiring_approval],
)
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build()
# Act
request_info_events: list[RequestInfoEvent] = []
async for event in workflow.run_stream("Invoke tool requiring approval"):
if isinstance(event, RequestInfoEvent):
request_info_events.append(event)
# Assert
assert len(request_info_events) == 1
approval_request = request_info_events[0]
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
output: str | None = None
async for event in workflow.send_responses_streaming({
approval_request.request_id: approval_request.data.create_response(True)
}):
if isinstance(event, WorkflowOutputEvent):
output = event.data
# Assert
assert output is not None
assert output == "Tool executed successfully."
async def test_agent_executor_parallel_tool_call_with_approval() -> None:
"""Test that AgentExecutor handles parallel tool calls requiring approval."""
# Arrange
agent = ChatAgent(
chat_client=MockChatClient(parallel_request=True),
name="ApprovalAgent",
tools=[mock_tool_requiring_approval],
)
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build()
# Act
events = await workflow.run("Invoke tool requiring approval")
# Assert
assert len(events.get_request_info_events()) == 2
for approval_request in events.get_request_info_events():
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
responses = {
approval_request.request_id: approval_request.data.create_response(True) # type: ignore
for approval_request in events.get_request_info_events()
}
events = await workflow.send_responses(responses)
# Assert
final_response = events.get_outputs()
assert len(final_response) == 1
assert final_response[0] == "Tool executed successfully."
async def test_agent_executor_parallel_tool_call_with_approval_streaming() -> None:
"""Test that AgentExecutor handles parallel tool calls requiring approval in streaming mode."""
# Arrange
agent = ChatAgent(
chat_client=MockChatClient(parallel_request=True),
name="ApprovalAgent",
tools=[mock_tool_requiring_approval],
)
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build()
# Act
request_info_events: list[RequestInfoEvent] = []
async for event in workflow.run_stream("Invoke tool requiring approval"):
if isinstance(event, RequestInfoEvent):
request_info_events.append(event)
# Assert
assert len(request_info_events) == 2
for approval_request in request_info_events:
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
responses = {
approval_request.request_id: approval_request.data.create_response(True) # type: ignore
for approval_request in request_info_events
}
output: str | None = None
async for event in workflow.send_responses_streaming(responses):
if isinstance(event, WorkflowOutputEvent):
output = event.data
# Assert
assert output is not None
assert output == "Tool executed successfully."
@@ -23,7 +23,7 @@ from agent_framework import (
WorkflowOutputEvent,
)
from agent_framework._mcp import MCPTool
from agent_framework._workflows._handoff import _clone_chat_agent
from agent_framework._workflows._handoff import _clone_chat_agent # type: ignore[reportPrivateUsage]
@dataclass
@@ -392,12 +392,218 @@ async def test_clone_chat_agent_preserves_mcp_tools() -> None:
)
assert hasattr(original_agent, "_local_mcp_tools")
assert len(original_agent._local_mcp_tools) == 1
assert original_agent._local_mcp_tools[0] == mock_mcp_tool
assert len(original_agent._local_mcp_tools) == 1 # type: ignore[reportPrivateUsage]
assert original_agent._local_mcp_tools[0] == mock_mcp_tool # type: ignore[reportPrivateUsage]
cloned_agent = _clone_chat_agent(original_agent)
assert hasattr(cloned_agent, "_local_mcp_tools")
assert len(cloned_agent._local_mcp_tools) == 1
assert cloned_agent._local_mcp_tools[0] == mock_mcp_tool
assert len(cloned_agent._local_mcp_tools) == 1 # type: ignore[reportPrivateUsage]
assert cloned_agent._local_mcp_tools[0] == mock_mcp_tool # type: ignore[reportPrivateUsage]
assert cloned_agent.chat_options.tools is not None
assert len(cloned_agent.chat_options.tools) == 1
async def test_return_to_previous_routing():
"""Test that return-to-previous routes back to the current specialist handling the conversation."""
triage = _RecordingAgent(name="triage", handoff_to="specialist_a")
specialist_a = _RecordingAgent(name="specialist_a", handoff_to="specialist_b")
specialist_b = _RecordingAgent(name="specialist_b")
workflow = (
HandoffBuilder(participants=[triage, specialist_a, specialist_b])
.set_coordinator(triage)
.add_handoff(triage, [specialist_a, specialist_b])
.add_handoff(specialist_a, specialist_b)
.enable_return_to_previous(True)
.with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 4)
.build()
)
# Start conversation - triage hands off to specialist_a
events = await _drain(workflow.run_stream("Initial request"))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
assert len(specialist_a.calls) > 0
# Specialist_a should have been called with initial request
initial_specialist_a_calls = len(specialist_a.calls)
# Second user message - specialist_a hands off to specialist_b
events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Need more help"}))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
# Specialist_b should have been called
assert len(specialist_b.calls) > 0
initial_specialist_b_calls = len(specialist_b.calls)
# Third user message - with return_to_previous, should route back to specialist_b (current agent)
events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Follow up question"}))
third_requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
# Specialist_b should have been called again (return-to-previous routes to current agent)
assert len(specialist_b.calls) > initial_specialist_b_calls, (
"Specialist B should be called again due to return-to-previous routing to current agent"
)
# Specialist_a should NOT be called again (it's no longer the current agent)
assert len(specialist_a.calls) == initial_specialist_a_calls, (
"Specialist A should not be called again - specialist_b is the current agent"
)
# Triage should only have been called once at the start
assert len(triage.calls) == 1, "Triage should only be called once (initial routing)"
# Verify awaiting_agent_id is set to specialist_b (the agent that just responded)
if third_requests:
user_input_req = third_requests[-1].data
assert isinstance(user_input_req, HandoffUserInputRequest)
assert user_input_req.awaiting_agent_id == "specialist_b", (
f"Expected awaiting_agent_id 'specialist_b' but got '{user_input_req.awaiting_agent_id}'"
)
async def test_return_to_previous_disabled_routes_to_coordinator():
"""Test that with return-to-previous disabled, routing goes back to coordinator."""
triage = _RecordingAgent(name="triage", handoff_to="specialist_a")
specialist_a = _RecordingAgent(name="specialist_a", handoff_to="specialist_b")
specialist_b = _RecordingAgent(name="specialist_b")
workflow = (
HandoffBuilder(participants=[triage, specialist_a, specialist_b])
.set_coordinator(triage)
.add_handoff(triage, [specialist_a, specialist_b])
.add_handoff(specialist_a, specialist_b)
.enable_return_to_previous(False)
.with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 3)
.build()
)
# Start conversation - triage hands off to specialist_a
events = await _drain(workflow.run_stream("Initial request"))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
assert len(triage.calls) == 1
# Second user message - specialist_a hands off to specialist_b
events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Need more help"}))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
# Third user message - without return_to_previous, should route back to triage
await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Follow up question"}))
# Triage should have been called twice total: initial + after specialist_b responds
assert len(triage.calls) == 2, "Triage should be called twice (initial + default routing to coordinator)"
async def test_return_to_previous_enabled():
"""Verify that enable_return_to_previous() keeps control with the current specialist."""
triage = _RecordingAgent(name="triage", handoff_to="specialist_a")
specialist_a = _RecordingAgent(name="specialist_a")
specialist_b = _RecordingAgent(name="specialist_b")
workflow = (
HandoffBuilder(participants=[triage, specialist_a, specialist_b])
.set_coordinator("triage")
.enable_return_to_previous(True)
.with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 3)
.build()
)
# Start conversation - triage hands off to specialist_a
events = await _drain(workflow.run_stream("Initial request"))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
assert len(triage.calls) == 1
assert len(specialist_a.calls) == 1
# Second user message - with return_to_previous, should route to specialist_a (not triage)
events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Follow up question"}))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
# Triage should only have been called once (initial) - specialist_a handles follow-up
assert len(triage.calls) == 1, "Triage should only be called once (initial)"
assert len(specialist_a.calls) == 2, "Specialist A should handle follow-up with return_to_previous enabled"
async def test_tool_choice_preserved_from_agent_config():
"""Verify that agent-level tool_choice configuration is preserved and not overridden."""
from unittest.mock import AsyncMock
from agent_framework import ChatResponse, ToolMode
# Create a mock chat client that records the tool_choice used
recorded_tool_choices: list[Any] = []
async def mock_get_response(messages: Any, **kwargs: Any) -> ChatResponse:
chat_options = kwargs.get("chat_options")
if chat_options:
recorded_tool_choices.append(chat_options.tool_choice)
return ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, text="Response")],
response_id="test_response",
)
mock_client = MagicMock()
mock_client.get_response = AsyncMock(side_effect=mock_get_response)
# Create agent with specific tool_choice configuration
agent = ChatAgent(
chat_client=mock_client,
name="test_agent",
tool_choice=ToolMode(mode="required"), # type: ignore[arg-type]
)
# Run the agent
await agent.run("Test message")
# Verify tool_choice was preserved
assert len(recorded_tool_choices) > 0, "No tool_choice recorded"
last_tool_choice = recorded_tool_choices[-1]
assert last_tool_choice is not None, "tool_choice should not be None"
assert str(last_tool_choice) == "required", f"Expected 'required', got {last_tool_choice}"
async def test_return_to_previous_state_serialization():
"""Test that return_to_previous state is properly serialized/deserialized for checkpointing."""
from agent_framework._workflows._handoff import _HandoffCoordinator # type: ignore[reportPrivateUsage]
# Create a coordinator with return_to_previous enabled
coordinator = _HandoffCoordinator(
starting_agent_id="triage",
specialist_ids={"specialist_a": "specialist_a", "specialist_b": "specialist_b"},
input_gateway_id="gateway",
termination_condition=lambda conv: False,
id="test-coordinator",
return_to_previous=True,
)
# Set the current agent (simulating a handoff scenario)
coordinator._current_agent_id = "specialist_a" # type: ignore[reportPrivateUsage]
# Snapshot the state
state = coordinator.snapshot_state()
# Verify pattern metadata includes current_agent_id
assert "metadata" in state
assert "current_agent_id" in state["metadata"]
assert state["metadata"]["current_agent_id"] == "specialist_a"
# Create a new coordinator and restore state
coordinator2 = _HandoffCoordinator(
starting_agent_id="triage",
specialist_ids={"specialist_a": "specialist_a", "specialist_b": "specialist_b"},
input_gateway_id="gateway",
termination_condition=lambda conv: False,
id="test-coordinator",
return_to_previous=True,
)
# Restore state
coordinator2.restore_state(state)
# Verify current_agent_id was restored
assert coordinator2._current_agent_id == "specialist_a", "Current agent should be restored from checkpoint" # type: ignore[reportPrivateUsage]
+84 -13
View File
@@ -62,6 +62,27 @@ serve(entities=[agent])
MCP tools use lazy initialization and connect automatically on first use. DevUI attempts to clean up connections on shutdown
## Resource Cleanup
Register cleanup hooks to properly close credentials and resources on shutdown:
```python
from azure.identity.aio import DefaultAzureCredential
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_devui import register_cleanup, serve
credential = DefaultAzureCredential()
client = AzureOpenAIChatClient()
agent = ChatAgent(name="MyAgent", chat_client=client)
# Register cleanup hook - credential will be closed on shutdown
register_cleanup(agent, credential.close)
serve(entities=[agent])
```
Works with multiple resources and file-based discovery. See tests for more examples.
## Directory Structure
For your agents to be discovered by the DevUI, they must be organized in a directory structure like below. Each agent/workflow must have an `__init__.py` that exports the required variable (`agent` or `workflow`).
@@ -91,15 +112,15 @@ devui ./agents --tracing framework
## OpenAI-Compatible API
For convenience, DevUI provides an OpenAI Responses backend API. This means you can run the backend and also use the OpenAI client sdk to connect to it. Use **agent/workflow name as the model**, and set streaming to `True` as needed.
For convenience, DevUI provides an OpenAI Responses backend API. This means you can run the backend and also use the OpenAI client sdk to connect to it. Use **agent/workflow name as the entity_id in metadata**, and set streaming to `True` as needed.
```bash
# Simple - use your entity name as the model
# Simple - use your entity name as the entity_id in metadata
curl -X POST http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-d @- << 'EOF'
{
"model": "weather_agent",
"metadata": {"entity_id": "weather_agent"},
"input": "Hello world"
}
```
@@ -115,7 +136,7 @@ client = OpenAI(
)
response = client.responses.create(
model="weather_agent", # Your agent/workflow name
metadata={"entity_id": "weather_agent"}, # Your agent/workflow name
input="What's the weather in Seattle?"
)
@@ -136,13 +157,13 @@ conversation = client.conversations.create(
# Use it across multiple turns
response1 = client.responses.create(
model="weather_agent",
metadata={"entity_id": "weather_agent"},
input="What's the weather in Seattle?",
conversation=conversation.id
)
response2 = client.responses.create(
model="weather_agent",
metadata={"entity_id": "weather_agent"},
input="How about tomorrow?",
conversation=conversation.id # Continues the conversation!
)
@@ -150,6 +171,22 @@ response2 = client.responses.create(
**How it works:** DevUI automatically retrieves the conversation's message history from the stored thread and passes it to the agent. You don't need to manually manage message history - just provide the same `conversation` ID for follow-up requests.
### OpenAI Proxy Mode
DevUI provides an **OpenAI Proxy** feature for testing OpenAI models directly through the interface without creating custom agents. Enable via Settings → OpenAI Proxy tab.
**How it works:** The UI sends requests to the DevUI backend (with `X-Proxy-Backend: openai` header), which then proxies them to OpenAI's Responses API (and Conversations API for multi-turn chats). This proxy approach keeps your `OPENAI_API_KEY` secure on the server—never exposed in the browser or client-side code.
**Example:**
```bash
curl -X POST http://localhost:8080/v1/responses \
-H "X-Proxy-Backend: openai" \
-d '{"model": "gpt-4.1-mini", "input": "Hello"}'
```
**Note:** Requires `OPENAI_API_KEY` environment variable configured on the backend.
## CLI Options
```bash
@@ -162,6 +199,21 @@ Options:
--config YAML config file
--tracing none|framework|workflow|all
--reload Enable auto-reload
--mode developer|user (default: developer)
--auth Enable Bearer token authentication
```
### UI Modes
- **developer** (default): Full access - debug panel, entity details, hot reload, deployment
- **user**: Simplified UI with restricted APIs - only chat and conversation management
```bash
# Development
devui ./agents
# Production (user-facing)
devui ./agents --mode user --auth
```
## Key Endpoints
@@ -187,18 +239,23 @@ Given that DevUI offers an OpenAI Responses API, it internally maps messages and
| `response.function_result.complete` | `FunctionResultContent` | DevUI |
| `response.function_approval.requested` | `FunctionApprovalRequestContent` | DevUI |
| `response.function_approval.responded` | `FunctionApprovalResponseContent` | DevUI |
| `response.output_item.added` (ResponseOutputImage) | `DataContent` (images) | DevUI |
| `response.output_item.added` (ResponseOutputFile) | `DataContent` (files) | DevUI |
| `response.output_item.added` (ResponseOutputData) | `DataContent` (other) | DevUI |
| `response.output_item.added` (ResponseOutputImage/File) | `UriContent` (images/files) | DevUI |
| `error` | `ErrorContent` | OpenAI |
| Final `Response.usage` field (not streamed) | `UsageContent` | OpenAI |
| | **Workflow Events** | |
| `response.output_item.added` (ExecutorActionItem)* | `ExecutorInvokedEvent` | OpenAI |
| `response.output_item.done` (ExecutorActionItem)* | `ExecutorCompletedEvent` | OpenAI |
| `response.output_item.done` (ExecutorActionItem with error)* | `ExecutorFailedEvent` | OpenAI |
| `response.output_item.added` (ResponseOutputMessage) | `WorkflowOutputEvent` | OpenAI |
| `response.workflow_event.complete` | `WorkflowEvent` (other) | DevUI |
| `response.trace.complete` | `WorkflowStatusEvent` | DevUI |
| `response.trace.complete` | `WorkflowWarningEvent` | DevUI |
| | **Trace Content** | |
| `response.trace.complete` | `DataContent` | DevUI |
| `response.trace.complete` | `UriContent` | DevUI |
| `response.trace.complete` | `DataContent` (no data/errors) | DevUI |
| `response.trace.complete` | `UriContent` (unsupported MIME) | DevUI |
| `response.trace.complete` | `HostedFileContent` | DevUI |
| `response.trace.complete` | `HostedVectorStoreContent` | DevUI |
@@ -213,15 +270,19 @@ DevUI follows the OpenAI Responses API specification for maximum compatibility:
**OpenAI Standard Event Types Used:**
- `ResponseOutputItemAddedEvent` - Output item notifications (function calls and results)
- `ResponseOutputItemAddedEvent` - Output item notifications (function calls, images, files, data)
- `ResponseOutputItemDoneEvent` - Output item completion notifications
- `Response.usage` - Token usage (in final response, not streamed)
- All standard text, reasoning, and function call events
**Custom DevUI Extensions:**
- `response.output_item.added` with custom item types:
- `ResponseOutputImage` - Agent-generated images (inline display)
- `ResponseOutputFile` - Agent-generated files (inline display)
- `ResponseOutputData` - Agent-generated structured data (inline display)
- `response.function_approval.requested` - Function approval requests (for interactive approval workflows)
- `response.function_approval.responded` - Function approval responses (user approval/rejection)
- `response.function_result.complete` - Server-side function execution results
- `response.workflow_event.complete` - Agent Framework workflow events
- `response.trace.complete` - Execution traces and internal content (DataContent, UriContent, hosted files/stores)
@@ -254,18 +315,28 @@ These custom extensions are clearly namespaced and can be safely ignored by stan
## Security
DevUI is designed as a **sample application for local development** and should not be exposed to untrusted networks or used in production environments.
DevUI is designed as a **sample application for local development** and should not be exposed to untrusted networks without proper authentication.
**For production deployments:**
```bash
# User mode with authentication (recommended)
devui ./agents --mode user --auth --host 0.0.0.0
```
This restricts developer APIs (reload, deployment, entity details) and requires Bearer token authentication.
**Security features:**
- User mode restricts developer-facing APIs
- Optional Bearer token authentication via `--auth`
- Only loads entities from local directories or in-memory registration
- No remote code execution capabilities
- Binds to localhost (127.0.0.1) by default
- All samples must be manually downloaded and reviewed before running
**Best practices:**
- Never expose DevUI to the internet
- Use `--mode user --auth` for any deployment exposed to end users
- Review all agent/workflow code before running
- Only load entities from trusted sources
- Use `.env` files for sensitive credentials (never commit them)
@@ -5,20 +5,87 @@
import importlib.metadata
import logging
import webbrowser
from collections.abc import Callable
from typing import Any
from ._conversations import CheckpointConversationManager
from ._server import DevServer
from .models import AgentFrameworkRequest, OpenAIError, OpenAIResponse, ResponseStreamEvent
from .models._discovery_models import DiscoveryResponse, EntityInfo, EnvVarRequirement
logger = logging.getLogger(__name__)
# Module-level cleanup registry (before serve() is called)
_cleanup_registry: dict[int, list[Callable[[], Any]]] = {}
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
def register_cleanup(entity: Any, *hooks: Callable[[], Any]) -> None:
"""Register cleanup hook(s) for an entity.
Cleanup hooks execute during DevUI server shutdown, before entity
clients are closed. Supports both synchronous and asynchronous callables.
Args:
entity: Agent, workflow, or other entity object
*hooks: One or more cleanup callables (sync or async)
Raises:
ValueError: If no hooks provided
Examples:
Single cleanup hook:
>>> from agent_framework.devui import serve, register_cleanup
>>> credential = DefaultAzureCredential()
>>> agent = ChatAgent(...)
>>> register_cleanup(agent, credential.close)
>>> serve(entities=[agent])
Multiple cleanup hooks:
>>> register_cleanup(agent, credential.close, session.close, db_pool.close)
Works with file-based discovery:
>>> # In agents/my_agent/agent.py
>>> from agent_framework.devui import register_cleanup
>>> credential = DefaultAzureCredential()
>>> agent = ChatAgent(...)
>>> register_cleanup(agent, credential.close)
>>> # Run: devui ./agents
"""
if not hooks:
raise ValueError("At least one cleanup hook required")
# Use id() to track entity identity (works across modules)
entity_id = id(entity)
if entity_id not in _cleanup_registry:
_cleanup_registry[entity_id] = []
_cleanup_registry[entity_id].extend(hooks)
logger.debug(
f"Registered {len(hooks)} cleanup hook(s) for {type(entity).__name__} "
f"(id: {entity_id}, total: {len(_cleanup_registry[entity_id])})"
)
def _get_registered_cleanup_hooks(entity: Any) -> list[Callable[[], Any]]:
"""Get cleanup hooks registered for an entity (internal use).
Args:
entity: Entity object to get hooks for
Returns:
List of cleanup hooks registered for the entity
"""
entity_id = id(entity)
return _cleanup_registry.get(entity_id, [])
def serve(
entities: list[Any] | None = None,
entities_dir: str | None = None,
@@ -28,6 +95,9 @@ def serve(
cors_origins: list[str] | None = None,
ui_enabled: bool = True,
tracing_enabled: bool = False,
mode: str = "developer",
auth_enabled: bool = False,
auth_token: str | None = None,
) -> None:
"""Launch Agent Framework DevUI with simple API.
@@ -40,6 +110,9 @@ def serve(
cors_origins: List of allowed CORS origins
ui_enabled: Whether to enable the UI
tracing_enabled: Whether to enable OpenTelemetry tracing
mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)
auth_enabled: Whether to enable Bearer token authentication
auth_token: Custom authentication token (auto-generated if not provided with auth_enabled=True)
"""
import re
@@ -53,6 +126,52 @@ def serve(
if not isinstance(port, int) or not (1 <= port <= 65535):
raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535")
# Security check: Warn if network-exposed without authentication
if host not in ("127.0.0.1", "localhost") and not auth_enabled:
logger.warning("⚠️ WARNING: Exposing DevUI to network without authentication!")
logger.warning("⚠️ This is INSECURE - anyone on your network can access your agents")
logger.warning("💡 For network exposure, add --auth flag: devui --host 0.0.0.0 --auth")
# Handle authentication configuration
if auth_enabled:
import os
import secrets
# Check if token is in environment variable first
if not auth_token:
auth_token = os.environ.get("DEVUI_AUTH_TOKEN")
# Auto-generate token if STILL not provided
if not auth_token:
# Check if we're in a production-like environment
is_production = (
host not in ("127.0.0.1", "localhost") # Exposed to network
or os.environ.get("CI") == "true" # Running in CI
or os.environ.get("KUBERNETES_SERVICE_HOST") # Running in k8s
)
if is_production:
# REFUSE to start without explicit token
logger.error("❌ Authentication enabled but no token provided")
logger.error("❌ Auto-generated tokens are NOT secure for network-exposed deployments")
logger.error("💡 Set token: export DEVUI_AUTH_TOKEN=<your-secure-token>")
logger.error("💡 Or pass: serve(entities=[...], auth_token='your-token')")
raise ValueError("DEVUI_AUTH_TOKEN required when host is not localhost")
# Development mode: auto-generate and show
auth_token = secrets.token_urlsafe(32)
logger.info("🔒 Authentication enabled with auto-generated token")
logger.info("\n" + "=" * 70)
logger.info("🔑 DEV TOKEN (localhost only, shown once):")
logger.info(f" {auth_token}")
logger.info("=" * 70 + "\n")
else:
logger.info("🔒 Authentication enabled with provided token")
# Set environment variable for server to use
os.environ["AUTH_REQUIRED"] = "true"
os.environ["DEVUI_AUTH_TOKEN"] = auth_token
# Configure tracing environment variables if enabled
if tracing_enabled:
import os
@@ -72,7 +191,12 @@ def serve(
# Create server with direct parameters
server = DevServer(
entities_dir=entities_dir, port=port, host=host, cors_origins=cors_origins, ui_enabled=ui_enabled
entities_dir=entities_dir,
port=port,
host=host,
cors_origins=cors_origins,
ui_enabled=ui_enabled,
mode=mode,
)
# Register in-memory entities if provided
@@ -139,6 +263,7 @@ def main() -> None:
# Export main public API
__all__ = [
"AgentFrameworkRequest",
"CheckpointConversationManager",
"DevServer",
"DiscoveryResponse",
"EntityInfo",
@@ -147,5 +272,6 @@ __all__ = [
"OpenAIResponse",
"ResponseStreamEvent",
"main",
"register_cleanup",
"serve",
]
@@ -55,6 +55,41 @@ Examples:
parser.add_argument("--tracing", action="store_true", help="Enable OpenTelemetry tracing for Agent Framework")
parser.add_argument(
"--mode",
choices=["developer", "user"],
default=None,
help="Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)",
)
# Add --dev/--no-dev as a convenient alternative to --mode
parser.add_argument(
"--dev",
dest="dev_mode",
action="store_true",
default=None,
help="Enable developer mode (shorthand for --mode developer)",
)
parser.add_argument(
"--no-dev",
dest="dev_mode",
action="store_false",
help="Disable developer mode (shorthand for --mode user)",
)
parser.add_argument(
"--auth",
action="store_true",
help="Enable authentication via Bearer token (required for deployed environments)",
)
parser.add_argument(
"--auth-token",
type=str,
help="Custom authentication token (auto-generated if not provided with --auth)",
)
parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}")
return parser
@@ -78,26 +113,35 @@ def validate_directory(directory: str) -> str:
abs_dir = os.path.abspath(directory)
if not os.path.exists(abs_dir):
print(f"Error: Directory '{directory}' does not exist", file=sys.stderr) # noqa: T201
print(f"Error: Directory '{directory}' does not exist", file=sys.stderr) # noqa: T201
sys.exit(1)
if not os.path.isdir(abs_dir):
print(f"Error: '{directory}' is not a directory", file=sys.stderr) # noqa: T201
print(f"Error: '{directory}' is not a directory", file=sys.stderr) # noqa: T201
sys.exit(1)
return abs_dir
def print_startup_info(entities_dir: str, host: str, port: int, ui_enabled: bool, reload: bool) -> None:
def print_startup_info(
entities_dir: str, host: str, port: int, ui_enabled: bool, reload: bool, auth_token: str | None = None
) -> None:
"""Print startup information."""
print("🤖 Agent Framework DevUI") # noqa: T201
print("Agent Framework DevUI") # noqa: T201
print("=" * 50) # noqa: T201
print(f"📁 Entities directory: {entities_dir}") # noqa: T201
print(f"🌐 Server URL: http://{host}:{port}") # noqa: T201
print(f"🎨 UI enabled: {'Yes' if ui_enabled else 'No'}") # noqa: T201
print(f"🔄 Auto-reload: {'Yes' if reload else 'No'}") # noqa: T201
print(f"Entities directory: {entities_dir}") # noqa: T201
print(f"Server URL: http://{host}:{port}") # noqa: T201
print(f"UI enabled: {'Yes' if ui_enabled else 'No'}") # noqa: T201
print(f"Auto-reload: {'Yes' if reload else 'No'}") # noqa: T201
# Display auth token if authentication is enabled
if auth_token:
print("Authentication: Enabled") # noqa: T201
print(f"Auth token: {auth_token}") # noqa: T201
print("💡 Use this token in Authorization: Bearer <token> header") # noqa: T201
print("=" * 50) # noqa: T201
print("🔍 Scanning for entities...") # noqa: T201
print("Scanning for entities...") # noqa: T201
def main() -> None:
@@ -114,8 +158,19 @@ def main() -> None:
# Extract parameters directly from args
ui_enabled = not args.headless
# Print startup info
print_startup_info(entities_dir, args.host, args.port, ui_enabled, args.reload)
# Determine mode from --mode or --dev/--no-dev flags
if args.dev_mode is not None:
# --dev or --no-dev was specified
mode = "developer" if args.dev_mode else "user"
elif args.mode is not None:
# --mode was specified
mode = args.mode
else:
# Default to developer mode
mode = "developer"
# Print startup info (don't show token - serve() will handle it)
print_startup_info(entities_dir, args.host, args.port, ui_enabled, args.reload, None)
# Import and start server
try:
@@ -128,14 +183,17 @@ def main() -> None:
auto_open=not args.no_open,
ui_enabled=ui_enabled,
tracing_enabled=args.tracing,
mode=mode,
auth_enabled=args.auth,
auth_token=args.auth_token, # Pass through explicit token only
)
except KeyboardInterrupt:
print("\n👋 Shutting down Agent Framework DevUI...") # noqa: T201
print("\nShutting down Agent Framework DevUI...") # noqa: T201
sys.exit(0)
except Exception as e:
logger.exception("Failed to start server")
print(f"Error: {e}", file=sys.stderr) # noqa: T201
print(f"Error: {e}", file=sys.stderr) # noqa: T201
sys.exit(1)
@@ -12,6 +12,7 @@ from abc import ABC, abstractmethod
from typing import Any, Literal, cast
from agent_framework import AgentThread, ChatMessage
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
from openai.types.conversations import Conversation, ConversationDeletedResource
from openai.types.conversations.conversation_item import ConversationItem
from openai.types.conversations.message import Message
@@ -26,6 +27,10 @@ from openai.types.responses import (
# Type alias for OpenAI Message role literals
MessageRole = Literal["unknown", "user", "assistant", "system", "critic", "discriminator", "developer", "tool"]
# Checkpoint item type constants
CONVERSATION_ITEM_TYPE_CHECKPOINT = "checkpoint"
CONVERSATION_TYPE_CHECKPOINT_CONTAINER = "checkpoint_container"
class ConversationStore(ABC):
"""Abstract base class for conversation storage.
@@ -35,14 +40,17 @@ class ConversationStore(ABC):
"""
@abstractmethod
def create_conversation(self, metadata: dict[str, str] | None = None) -> Conversation:
def create_conversation(
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
) -> Conversation:
"""Create a new conversation (wraps AgentThread creation).
Args:
metadata: Optional metadata dict (e.g., {"agent_id": "weather_agent"})
conversation_id: Optional conversation ID (if None, generates one)
Returns:
Conversation object with generated ID
Conversation object with generated or provided ID
"""
pass
@@ -127,7 +135,7 @@ class ConversationStore(ABC):
@abstractmethod
def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
"""Get specific conversation item.
"""Get a specific conversation item by ID.
Args:
conversation_id: Conversation ID
@@ -184,17 +192,23 @@ class InMemoryConversationStore(ConversationStore):
# Item index for O(1) lookup: {conversation_id: {item_id: ConversationItem}}
self._item_index: dict[str, dict[str, ConversationItem]] = {}
def create_conversation(self, metadata: dict[str, str] | None = None) -> Conversation:
"""Create a new conversation with underlying AgentThread."""
conv_id = f"conv_{uuid.uuid4().hex}"
def create_conversation(
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
) -> Conversation:
"""Create a new conversation with underlying AgentThread and checkpoint storage."""
conv_id = conversation_id or f"conv_{uuid.uuid4().hex}"
created_at = int(time.time())
# Create AgentThread with default ChatMessageStore
thread = AgentThread()
# Create session-scoped checkpoint storage (one per conversation)
checkpoint_storage = InMemoryCheckpointStorage()
self._conversations[conv_id] = {
"id": conv_id,
"thread": thread,
"checkpoint_storage": checkpoint_storage, # Stored alongside thread
"metadata": metadata or {},
"created_at": created_at,
"items": [],
@@ -424,6 +438,23 @@ class InMemoryConversationStore(ConversationStore):
# Add function result items
items.extend(function_results)
# Include checkpoints from checkpoint storage as conversation items
checkpoint_storage = conv_data.get("checkpoint_storage")
if checkpoint_storage:
# Get all checkpoints for this conversation
checkpoints = await checkpoint_storage.list_checkpoints()
for checkpoint in checkpoints:
# Create a conversation item for each checkpoint
checkpoint_item = {
"id": f"checkpoint_{checkpoint.checkpoint_id}",
"type": "checkpoint",
"checkpoint_id": checkpoint.checkpoint_id,
"workflow_id": checkpoint.workflow_id,
"timestamp": checkpoint.timestamp,
"status": "completed",
}
items.append(cast(ConversationItem, checkpoint_item))
# Apply pagination
if order == "desc":
items = items[::-1]
@@ -442,12 +473,9 @@ class InMemoryConversationStore(ConversationStore):
return paginated_items, has_more
def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None:
"""Get specific conversation item - O(1) lookup via index."""
# Use index for O(1) lookup instead of linear search
conv_items = self._item_index.get(conversation_id)
if not conv_items:
return None
"""Get a specific conversation item by ID."""
# Use the item index for O(1) lookup
conv_items = self._item_index.get(conversation_id, {})
return conv_items.get(item_id)
def get_thread(self, conversation_id: str) -> AgentThread | None:
@@ -471,3 +499,42 @@ class InMemoryConversationStore(ConversationStore):
)
)
return results
class CheckpointConversationManager:
"""Manages checkpoint storage for workflow sessions - SESSION-SCOPED.
Simplified architecture: Each conversation has its own InMemoryCheckpointStorage
stored in conv_data["checkpoint_storage"]. This manager just retrieves it.
Session isolation comes from each conversation having a separate storage instance.
"""
def __init__(self, conversation_store: ConversationStore):
# Runtime validation since we need specific implementation details
if not isinstance(conversation_store, InMemoryConversationStore):
raise TypeError("CheckpointConversationManager currently requires InMemoryConversationStore")
self._store: InMemoryConversationStore = conversation_store
# Keep public reference for backward compatibility with tests
self.conversation_store = conversation_store
def get_checkpoint_storage(self, conversation_id: str) -> InMemoryCheckpointStorage:
"""Get the checkpoint storage for a specific conversation.
Args:
conversation_id: Conversation ID
Returns:
InMemoryCheckpointStorage instance for this conversation
Raises:
ValueError: If conversation not found
"""
# Access internal conversations dict (we know it's InMemoryConversationStore)
conv_data = self._store._conversations.get(conversation_id)
if not conv_data:
raise ValueError(f"Conversation {conversation_id} not found")
checkpoint_storage = conv_data["checkpoint_storage"]
if not isinstance(checkpoint_storage, InMemoryCheckpointStorage):
raise TypeError(f"Expected InMemoryCheckpointStorage but got {type(checkpoint_storage)}")
return checkpoint_storage
@@ -0,0 +1,588 @@
# Copyright (c) Microsoft. All rights reserved.
"""Azure Container Apps deployment manager for DevUI entities."""
import asyncio
import logging
import re
import secrets
import uuid
from collections.abc import AsyncGenerator
from datetime import datetime, timezone
from pathlib import Path
from .models._discovery_models import Deployment, DeploymentConfig, DeploymentEvent
logger = logging.getLogger(__name__)
class DeploymentManager:
"""Manages entity deployments to Azure Container Apps."""
def __init__(self) -> None:
"""Initialize deployment manager."""
self._deployments: dict[str, Deployment] = {}
async def deploy(self, config: DeploymentConfig, entity_path: Path) -> AsyncGenerator[DeploymentEvent, None]:
"""Deploy entity to Azure Container Apps with streaming events.
Args:
config: Deployment configuration
entity_path: Path to entity directory
Yields:
DeploymentEvent objects for real-time progress updates
Raises:
ValueError: If prerequisites not met or deployment fails
"""
deployment_id = str(uuid.uuid4())
try:
# Step 1: Validate prerequisites
yield DeploymentEvent(
type="deploy.validating",
message="Checking prerequisites (Azure CLI, Docker, authentication)...",
)
await self._validate_prerequisites()
# Step 2: Generate Dockerfile
yield DeploymentEvent(
type="deploy.dockerfile",
message="Generating Dockerfile with authentication enabled...",
)
_ = await self._generate_dockerfile(entity_path, config)
# Step 3: Generate auth token
yield DeploymentEvent(
type="deploy.token",
message="Generating secure authentication token...",
)
auth_token = secrets.token_urlsafe(32)
# Step 4: Discover existing Container App Environment
yield DeploymentEvent(
type="deploy.environment",
message="Checking for existing Container App Environment...",
)
# Step 5: Build and deploy with Azure CLI
yield DeploymentEvent(
type="deploy.building",
message=f"Deploying to Azure Container Apps ({config.region})...",
)
# Create a queue for streaming events from subprocess
event_queue: asyncio.Queue[DeploymentEvent] = asyncio.Queue()
# Run deployment in background task with event queue
deployment_task = asyncio.create_task(self._deploy_to_azure(config, entity_path, auth_token, event_queue))
# Stream events from queue while deployment runs
while True:
try:
# Check if deployment task is done
if deployment_task.done():
# Get the result or exception
deployment_url = await deployment_task
break
# Get event from queue with short timeout
event = await asyncio.wait_for(event_queue.get(), timeout=0.1)
yield event
except asyncio.TimeoutError:
# No event in queue, continue waiting
continue
# Step 5: Store deployment record
deployment = Deployment(
id=deployment_id,
entity_id=config.entity_id,
resource_group=config.resource_group,
app_name=config.app_name,
region=config.region,
url=deployment_url,
status="deployed",
created_at=datetime.now(timezone.utc).isoformat(),
)
self._deployments[deployment_id] = deployment
# Step 6: Success - return URL and token
yield DeploymentEvent(
type="deploy.completed",
message=f"Deployment successful! URL: {deployment_url}",
url=deployment_url,
auth_token=auth_token, # Shown once to user
)
except Exception as e:
error_msg = f"Deployment failed: {e!s}"
logger.exception(error_msg)
# Store failed deployment
deployment = Deployment(
id=deployment_id,
entity_id=config.entity_id,
resource_group=config.resource_group,
app_name=config.app_name,
region=config.region,
url="",
status="failed",
created_at=datetime.now(timezone.utc).isoformat(),
error=str(e),
)
self._deployments[deployment_id] = deployment
yield DeploymentEvent(
type="deploy.failed",
message=error_msg,
)
async def _validate_prerequisites(self) -> None:
"""Validate that Azure CLI, Docker, authentication, and resource providers are available.
Raises:
ValueError: If prerequisites not met
"""
# Check Azure CLI
az_check = await asyncio.create_subprocess_exec(
"az", "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
await az_check.communicate()
if az_check.returncode != 0:
raise ValueError(
"Azure CLI not found. Install from: https://learn.microsoft.com/cli/azure/install-azure-cli"
)
# Check Docker
docker_check = await asyncio.create_subprocess_exec(
"docker", "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
await docker_check.communicate()
if docker_check.returncode != 0:
raise ValueError("Docker not found. Install from: https://www.docker.com/get-started")
# Check Azure authentication
az_account_check = await asyncio.create_subprocess_exec(
"az", "account", "show", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, _ = await az_account_check.communicate()
if az_account_check.returncode != 0:
raise ValueError("Not authenticated with Azure. Run: az login")
# Check required resource providers are registered
required_providers = ["Microsoft.App", "Microsoft.ContainerRegistry", "Microsoft.OperationalInsights"]
unregistered_providers = []
# Get list of registered providers
provider_check = await asyncio.create_subprocess_exec(
"az",
"provider",
"list",
"--query",
"[?registrationState=='Registered'].namespace",
"--output",
"json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _stderr = await provider_check.communicate()
if provider_check.returncode == 0:
import json
try:
registered = json.loads(stdout.decode())
for provider in required_providers:
if provider not in registered:
unregistered_providers.append(provider)
except json.JSONDecodeError:
logger.warning("Could not parse provider list, skipping provider validation")
else:
logger.warning("Could not check provider registration status")
if unregistered_providers:
commands = [f"az provider register -n {p} --wait" for p in unregistered_providers]
raise ValueError(
f"Required Azure resource providers not registered: {', '.join(unregistered_providers)}\n\n"
f"Register them by running:\n" + "\n".join(commands) + "\n\n"
"This is a one-time setup per Azure subscription."
)
logger.info("All prerequisites validated successfully")
async def _generate_dockerfile(self, entity_path: Path, config: DeploymentConfig) -> Path:
"""Generate Dockerfile for entity deployment.
Args:
entity_path: Path to entity directory
config: Deployment configuration
Returns:
Path to generated Dockerfile
"""
# Validate ui_mode
if config.ui_mode not in ["user", "developer"]:
raise ValueError(f"Invalid ui_mode: {config.ui_mode}. Must be 'user' or 'developer'.")
# Check if requirements.txt exists in the entity directory
has_requirements = (entity_path / "requirements.txt").exists()
requirements_section = ""
if has_requirements:
logger.info(f"Found requirements.txt in {entity_path}, will include in Dockerfile")
requirements_section = """# Install entity dependencies
COPY requirements.txt ./
RUN pip install -r requirements.txt
"""
else:
logger.info(f"No requirements.txt found in {entity_path}, skipping dependency installation")
dockerfile_content = f"""FROM python:3.11-slim
WORKDIR /app
{requirements_section}# Install DevUI from PyPI
RUN pip install agent-framework-devui --pre
# Copy entity code
COPY . /app/entity/
ENV PORT=8080
EXPOSE 8080
# Launch DevUI with auth enabled (token from environment variable)
CMD ["devui", "/app/entity", "--mode", "{config.ui_mode}", "--host", "0.0.0.0", "--port", "8080", "--auth"]
"""
dockerfile_path = entity_path / "Dockerfile"
# Warn if Dockerfile already exists
if dockerfile_path.exists():
logger.warning(f"Dockerfile already exists at {dockerfile_path}, overwriting...")
dockerfile_path.write_text(dockerfile_content)
logger.info(f"Generated Dockerfile at {dockerfile_path}")
return dockerfile_path
async def _discover_container_app_environment(self, resource_group: str, region: str) -> str | None:
"""Discover existing Container App Environment in resource group.
Args:
resource_group: Resource group name
region: Azure region (for filtering if needed)
Returns:
Environment name if found, None otherwise
"""
cmd = [
"az",
"containerapp",
"env",
"list",
"--resource-group",
resource_group,
"--query",
"[0].name",
"--output",
"tsv",
]
logger.info(f"Discovering existing Container App Environments in {resource_group}...")
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
env_name = stdout.decode().strip()
if env_name:
logger.info(f"Found existing environment: {env_name}")
return env_name
logger.info("No existing environments found in resource group")
return None
logger.warning(f"Failed to query environments: {stderr.decode()}")
return None
async def _deploy_to_azure(
self, config: DeploymentConfig, entity_path: Path, auth_token: str, event_queue: asyncio.Queue[DeploymentEvent]
) -> str:
"""Deploy to Azure Container Apps, reusing existing environments.
Args:
config: Deployment configuration
entity_path: Path to entity directory
auth_token: Authentication token to inject
event_queue: Queue for streaming progress events
Returns:
Deployment URL
Raises:
ValueError: If deployment fails
"""
# Step 1: Try to discover existing Container App Environment
existing_env = await self._discover_container_app_environment(config.resource_group, config.region)
if existing_env:
# Use existing environment - avoids needing environment creation permissions
logger.info(f"Reusing existing Container App Environment: {existing_env} (cost efficient, no side effects)")
cmd = [
"az",
"containerapp",
"up",
"--name",
config.app_name,
"--resource-group",
config.resource_group,
"--environment",
existing_env,
"--source",
str(entity_path),
"--env-vars",
f"DEVUI_AUTH_TOKEN={auth_token}",
"--ingress",
"external",
"--target-port",
"8080",
]
logger.info(f"Creating new Container App '{config.app_name}' in environment '{existing_env}'...")
else:
# No existing environment - try to create one (may fail if no permissions)
logger.warning(
"No existing Container App Environment found. "
"Attempting to create new environment (requires Microsoft.App/managedEnvironments/write permission)..."
)
cmd = [
"az",
"containerapp",
"up",
"--name",
config.app_name,
"--resource-group",
config.resource_group,
"--location",
config.region,
"--source",
str(entity_path),
"--env-vars",
f"DEVUI_AUTH_TOKEN={auth_token}",
"--ingress",
"external",
"--target-port",
"8080",
]
logger.info(f"Running: {' '.join(cmd)}")
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
)
# Stream output line by line
output_lines = []
try:
if not process.stdout:
raise ValueError("Failed to capture process output")
while True:
# Read with timeout
line = await asyncio.wait_for(process.stdout.readline(), timeout=600)
if not line:
break
line_text = line.decode().strip()
if line_text:
output_lines.append(line_text)
# Stream meaningful updates to user
if "WARNING:" in line_text:
# Parse and send user-friendly warnings
if "Creating resource group" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress",
message=f"Creating resource group '{config.resource_group}'...",
)
)
elif "Creating ContainerAppEnvironment" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress",
message="Setting up Container App Environment (this may take 2-3 minutes)...",
)
)
elif "Registering resource provider" in line_text:
provider = line_text.split("provider")[-1].strip()
if provider.endswith("..."):
provider = provider[:-3]
await event_queue.put(
DeploymentEvent(
type="deploy.progress", message=f"Registering Azure provider{provider}..."
)
)
elif "Creating Azure Container Registry" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress", message="Creating Container Registry for your images..."
)
)
elif "No Log Analytics workspace" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress", message="Creating Log Analytics workspace for monitoring..."
)
)
elif "Building image" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress",
message="Building Docker image (this may take several minutes)...",
)
)
elif "Pushing image" in line_text:
await event_queue.put(
DeploymentEvent(
type="deploy.progress", message="Pushing image to Azure Container Registry..."
)
)
elif "Creating Container App" in line_text:
await event_queue.put(
DeploymentEvent(type="deploy.progress", message="Creating your Container App...")
)
elif "Container app created" in line_text:
await event_queue.put(
DeploymentEvent(type="deploy.progress", message="Container app created successfully!")
)
elif "ERROR:" in line_text:
# Stream errors immediately
await event_queue.put(DeploymentEvent(type="deploy.error", message=line_text))
elif "Step" in line_text and "/" in line_text:
# Docker build steps
await event_queue.put(
DeploymentEvent(type="deploy.progress", message=f"Docker build: {line_text}")
)
elif "https://" in line_text and ".azurecontainerapps.io" in line_text:
# Deployment URL detected
await event_queue.put(
DeploymentEvent(type="deploy.progress", message="Deployment URL generated!")
)
# Wait for process to complete
return_code = await process.wait()
if return_code != 0:
error_output = "\n".join(output_lines[-10:]) # Last 10 lines for context
raise ValueError(f"Azure deployment failed:\n{error_output}")
except asyncio.TimeoutError as e:
process.kill()
raise ValueError(
"Azure deployment timed out after 10 minutes. Please check Azure portal for status."
) from e
# Parse output to extract FQDN
output = "\n".join(output_lines)
logger.debug(f"Azure CLI output: {output}")
# Extract FQDN from output (az containerapp up returns it)
# Format: https://<app-name>.<random-id>.<region>.azurecontainerapps.io
deployment_url = self._extract_fqdn_from_output(output, config.app_name)
logger.info(f"Deployment successful: {deployment_url}")
return deployment_url
def _extract_fqdn_from_output(self, output: str, app_name: str) -> str:
"""Extract FQDN from Azure CLI output.
Args:
output: Azure CLI command output
app_name: Container app name
Returns:
Full HTTPS URL to deployed app
"""
# Try to find FQDN in output
for line in output.split("\n"):
if "fqdn" in line.lower() or app_name in line:
# Extract URL-like string
match = re.search(r"https?://[\w\-\.]+\.azurecontainerapps\.io", line)
if match:
return match.group(0)
# If we can't extract FQDN, fail explicitly rather than return a broken URL
logger.error(f"Could not extract FQDN from Azure CLI output. Output:\n{output}")
raise ValueError(
"Could not extract deployment URL from Azure CLI output. "
"The deployment may have succeeded - check the Azure portal for your container app URL."
)
async def list_deployments(self, entity_id: str | None = None) -> list[Deployment]:
"""List all deployments, optionally filtered by entity.
Args:
entity_id: Optional entity ID to filter by
Returns:
List of deployment records
"""
if entity_id:
return [d for d in self._deployments.values() if d.entity_id == entity_id]
return list(self._deployments.values())
async def get_deployment(self, deployment_id: str) -> Deployment | None:
"""Get deployment by ID.
Args:
deployment_id: Deployment ID
Returns:
Deployment record or None if not found
"""
return self._deployments.get(deployment_id)
async def delete_deployment(self, deployment_id: str) -> None:
"""Delete deployment from Azure Container Apps.
Args:
deployment_id: Deployment ID to delete
Raises:
ValueError: If deployment not found or deletion fails
"""
deployment = self._deployments.get(deployment_id)
if not deployment:
raise ValueError(f"Deployment {deployment_id} not found")
# Execute: az containerapp delete
cmd = [
"az",
"containerapp",
"delete",
"--name",
deployment.app_name,
"--resource-group",
deployment.resource_group,
"--yes", # Skip confirmation
]
logger.info(f"Deleting deployment: {' '.join(cmd)}")
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_output = stderr.decode() if stderr else stdout.decode()
raise ValueError(f"Deployment deletion failed: {error_output}")
# Remove from store
del self._deployments[deployment_id]
logger.info(f"Deployment {deployment_id} deleted successfully")
@@ -4,6 +4,7 @@
from __future__ import annotations
import ast
import importlib
import importlib.util
import logging
@@ -31,6 +32,7 @@ class EntityDiscovery:
self.entities_dir = entities_dir
self._entities: dict[str, EntityInfo] = {}
self._loaded_objects: dict[str, Any] = {}
self._cleanup_hooks: dict[str, list[Any]] = {}
async def discover_entities(self) -> list[EntityInfo]:
"""Scan for Agent Framework entities.
@@ -70,14 +72,15 @@ class EntityDiscovery:
"""
return self._loaded_objects.get(entity_id)
async def load_entity(self, entity_id: str) -> Any:
"""Load entity on-demand (lazy loading).
async def load_entity(self, entity_id: str, checkpoint_manager: Any = None) -> Any:
"""Load entity on-demand and inject checkpoint storage for workflows.
This method implements lazy loading by importing the entity module only when needed.
In-memory entities are returned from cache immediately.
Args:
entity_id: Entity identifier
checkpoint_manager: Optional checkpoint manager for workflow storage injection
Returns:
Loaded entity object
@@ -107,9 +110,13 @@ class EntityDiscovery:
else:
raise ValueError(
f"Unsupported entity source: {entity_info.source}. "
f"Only 'directory' and 'in_memory' sources are supported."
f"Only 'directory' and 'in-memory' sources are supported."
)
# Note: Checkpoint storage is now injected at runtime via run_stream() parameter,
# not at load time. This provides cleaner architecture and explicit control flow.
# See _executor.py _execute_workflow() for runtime checkpoint storage injection.
# Enrich metadata with actual entity data
# Don't pass entity_type if it's "unknown" - let inference determine the real type
enriched_info = await self.create_entity_info_from_object(
@@ -122,11 +129,27 @@ class EntityDiscovery:
# Preserve the original path from sparse metadata
if "path" in entity_info.metadata:
enriched_info.metadata["path"] = entity_info.metadata["path"]
# Now that we have the path, properly check deployment support
entity_path = Path(entity_info.metadata["path"])
deployment_supported, deployment_reason = self._check_deployment_support(entity_path, entity_info.source)
enriched_info.deployment_supported = deployment_supported
enriched_info.deployment_reason = deployment_reason
enriched_info.metadata["lazy_loaded"] = True
self._entities[entity_id] = enriched_info
# Cache the loaded object
self._loaded_objects[entity_id] = entity_obj
# Check module-level registry for cleanup hooks
from . import _get_registered_cleanup_hooks
registered_hooks = _get_registered_cleanup_hooks(entity_obj)
if registered_hooks:
if entity_id not in self._cleanup_hooks:
self._cleanup_hooks[entity_id] = []
self._cleanup_hooks[entity_id].extend(registered_hooks)
logger.debug(f"Discovered {len(registered_hooks)} registered cleanup hook(s) for: {entity_id}")
logger.info(f"Successfully loaded entity: {entity_id} (type: {enriched_info.type})")
return entity_obj
@@ -187,6 +210,17 @@ class EntityDiscovery:
"""
return list(self._entities.values())
def get_cleanup_hooks(self, entity_id: str) -> list[Any]:
"""Get cleanup hooks registered for an entity.
Args:
entity_id: Entity identifier
Returns:
List of cleanup hooks for the entity
"""
return self._cleanup_hooks.get(entity_id, [])
def invalidate_entity(self, entity_id: str) -> None:
"""Invalidate (clear cache for) an entity to enable hot reload.
@@ -239,6 +273,17 @@ class EntityDiscovery:
"""
self._entities[entity_id] = entity_info
self._loaded_objects[entity_id] = entity_object
# Check module-level registry for cleanup hooks
from . import _get_registered_cleanup_hooks
registered_hooks = _get_registered_cleanup_hooks(entity_object)
if registered_hooks:
if entity_id not in self._cleanup_hooks:
self._cleanup_hooks[entity_id] = []
self._cleanup_hooks[entity_id].extend(registered_hooks)
logger.debug(f"Discovered {len(registered_hooks)} registered cleanup hook(s) for: {entity_id}")
logger.debug(f"Registered entity: {entity_id} ({entity_info.type})")
async def create_entity_info_from_object(
@@ -305,6 +350,17 @@ class EntityDiscovery:
elif not has_run_stream and not has_run:
logger.warning(f"Agent '{entity_id}' lacks both run() and run_stream() methods. May not work.")
# Check deployment support based on source
# For directory-based entities, we need the path to verify deployment support
deployment_supported = False
deployment_reason = "In-memory entities cannot be deployed (no source directory)"
if source == "directory":
# Directory-based entity - will be checked properly after enrichment when path is available
# For now, mark as potentially deployable - will be re-evaluated after enrichment
deployment_supported = True
deployment_reason = "Ready for deployment (pending path verification)"
# Create EntityInfo with Agent Framework specifics
return EntityInfo(
id=entity_id,
@@ -321,6 +377,8 @@ class EntityDiscovery:
executors=tools_list if entity_type == "workflow" else [],
input_schema={"type": "string"}, # Default schema
start_executor_id=tools_list[0] if tools_list and entity_type == "workflow" else None,
deployment_supported=deployment_supported,
deployment_reason=deployment_reason,
metadata={
"source": "agent_framework_object",
"class_name": entity_object.__class__.__name__
@@ -404,6 +462,31 @@ class EntityDiscovery:
# Has __init__.py but no specific file
return "unknown"
def _check_deployment_support(self, entity_path: Path, source: str) -> tuple[bool, str | None]:
"""Check if entity can be deployed to Azure Container Apps.
Args:
entity_path: Path to entity directory or file
source: Entity source ("directory" or "in_memory")
Returns:
Tuple of (supported, reason) explaining deployment eligibility
"""
# In-memory entities cannot be deployed
if source == "in_memory":
return False, "In-memory entities cannot be deployed (no source directory)"
# File-based entities need a directory structure for deployment
if not entity_path.is_dir():
return False, "Only directory-based entities can be deployed"
# Must have __init__.py
if not (entity_path / "__init__.py").exists():
return False, "Missing __init__.py file"
# Passed all checks
return True, "Ready for deployment"
def _register_sparse_entity(self, dir_path: Path) -> None:
"""Register entity with sparse metadata (no import).
@@ -413,6 +496,9 @@ class EntityDiscovery:
entity_id = dir_path.name
entity_type = self._detect_entity_type(dir_path)
# Check deployment support
deployment_supported, deployment_reason = self._check_deployment_support(dir_path, "directory")
entity_info = EntityInfo(
id=entity_id,
name=entity_id.replace("_", " ").title(),
@@ -421,6 +507,8 @@ class EntityDiscovery:
tools=[], # Sparse - will be populated on load
description="", # Sparse - will be populated on load
source="directory",
deployment_supported=deployment_supported,
deployment_reason=deployment_reason,
metadata={
"path": str(dir_path),
"discovered": True,
@@ -431,14 +519,52 @@ class EntityDiscovery:
self._entities[entity_id] = entity_info
logger.debug(f"Registered sparse entity: {entity_id} (type: {entity_type})")
def _has_entity_exports(self, file_path: Path) -> bool:
"""Check if a Python file has entity exports (agent or workflow) using AST parsing.
This safely checks for module-level assignments like:
- agent = ChatAgent(...)
- workflow = WorkflowBuilder()...
Args:
file_path: Python file to check
Returns:
True if file has 'agent' or 'workflow' exports
"""
try:
# Read and parse the file's AST
source = file_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(file_path))
# Look for module-level assignments of 'agent' or 'workflow'
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id in ("agent", "workflow"):
return True
except Exception as e:
logger.debug(f"Could not parse {file_path} for entity exports: {e}")
return False
return False
def _register_sparse_file_entity(self, file_path: Path) -> None:
"""Register file-based entity with sparse metadata (no import).
Args:
file_path: Entity Python file
"""
# Check if file has valid entity exports using AST parsing
if not self._has_entity_exports(file_path):
logger.debug(f"Skipping {file_path.name} - no 'agent' or 'workflow' exports found")
return
entity_id = file_path.stem
# Check deployment support (file-based entities cannot be deployed)
deployment_supported, deployment_reason = self._check_deployment_support(file_path, "directory")
# File-based entities are typically agents, but we can't know for sure without importing
entity_info = EntityInfo(
id=entity_id,
@@ -448,6 +574,8 @@ class EntityDiscovery:
tools=[],
description="",
source="directory",
deployment_supported=deployment_supported,
deployment_reason=deployment_reason,
metadata={
"path": str(file_path),
"discovered": True,
@@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator
from typing import Any
from agent_framework import AgentProtocol
from agent_framework._workflows._events import RequestInfoEvent
from ._conversations import ConversationStore, InMemoryConversationStore
from ._discovery import EntityDiscovery
@@ -50,6 +51,11 @@ class AgentFrameworkExecutor:
# Use provided conversation store or default to in-memory
self.conversation_store = conversation_store or InMemoryConversationStore()
# Create checkpoint manager (wraps conversation store)
from ._conversations import CheckpointConversationManager
self.checkpoint_manager = CheckpointConversationManager(self.conversation_store)
def _setup_tracing_provider(self) -> None:
"""Set up our own TracerProvider so we can add processors."""
try:
@@ -79,10 +85,20 @@ class AgentFrameworkExecutor:
# Configure Agent Framework tracing only if ENABLE_OTEL is set
if os.environ.get("ENABLE_OTEL"):
try:
from agent_framework.observability import setup_observability
from agent_framework.observability import OBSERVABILITY_SETTINGS, setup_observability
setup_observability(enable_sensitive_data=True)
logger.info("Enabled Agent Framework observability")
# Only configure if not already executed
if not OBSERVABILITY_SETTINGS._executed_setup:
# Get OTLP endpoint from either custom or standard env var
# This handles the case where env vars are set after ObservabilitySettings was imported
otlp_endpoint = os.environ.get("OTLP_ENDPOINT") or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
# Pass the endpoint explicitly to setup_observability
# This ensures OTLP exporters are created even if env vars were set late
setup_observability(enable_sensitive_data=True, otlp_endpoint=otlp_endpoint)
logger.info("Enabled Agent Framework observability")
else:
logger.debug("Agent Framework observability already configured")
except Exception as e:
logger.warning(f"Failed to enable Agent Framework observability: {e}")
else:
@@ -173,7 +189,7 @@ class AgentFrameworkExecutor:
entity_info = self.get_entity_info(entity_id)
# Trigger lazy loading (will return from cache if already loaded)
entity_obj = await self.entity_discovery.load_entity(entity_id)
entity_obj = await self.entity_discovery.load_entity(entity_id, checkpoint_manager=self.checkpoint_manager)
if not entity_obj:
raise EntityNotFoundError(f"Entity object for '{entity_id}' not found")
@@ -190,6 +206,15 @@ class AgentFrameworkExecutor:
yield event
elif entity_info.type == "workflow":
async for event in self._execute_workflow(entity_obj, request, trace_collector):
# Log RequestInfoEvent for debugging HIL flow
event_class = event.__class__.__name__ if hasattr(event, "__class__") else type(event).__name__
if event_class == "RequestInfoEvent":
logger.info("🔔 [EXECUTOR] RequestInfoEvent detected from workflow!")
logger.info(f" request_id: {getattr(event, 'request_id', 'N/A')}")
logger.info(f" source_executor_id: {getattr(event, 'source_executor_id', 'N/A')}")
logger.info(f" request_type: {getattr(event, 'request_type', 'N/A')}")
data = getattr(event, "data", None)
logger.info(f" data type: {type(data).__name__ if data else 'None'}")
yield event
else:
raise ValueError(f"Unsupported entity type: {entity_info.type}")
@@ -289,7 +314,7 @@ class AgentFrameworkExecutor:
async def _execute_workflow(
self, workflow: Any, request: AgentFrameworkRequest, trace_collector: Any
) -> AsyncGenerator[Any, None]:
"""Execute Agent Framework workflow with trace collection.
"""Execute Agent Framework workflow with checkpoint support via conversation items.
Args:
workflow: Workflow object to execute
@@ -300,23 +325,199 @@ class AgentFrameworkExecutor:
Workflow events and trace events
"""
try:
# Get input data directly from request.input field
input_data = request.input
logger.debug(f"Using input field: {type(input_data)}")
entity_id = request.get_entity_id() or "unknown"
# Parse input based on workflow's expected input type
parsed_input = await self._parse_workflow_input(workflow, input_data)
# Get or create session conversation for checkpoint storage
conversation_id = request.get_conversation_id()
if not conversation_id:
# Create default session if not provided
import time
import uuid
logger.debug(f"Executing workflow with parsed input type: {type(parsed_input)}")
conversation_id = f"session_{entity_id}_{uuid.uuid4().hex[:8]}"
logger.info(f"Created new workflow session: {conversation_id}")
# Use Agent Framework workflow's native streaming
async for event in workflow.run_stream(parsed_input):
# Yield any pending trace events first
for trace_event in trace_collector.get_pending_events():
yield trace_event
# Create conversation in store
self.conversation_store.create_conversation(
metadata={
"entity_id": entity_id,
"type": "workflow_session",
"created_at": str(int(time.time())),
},
conversation_id=conversation_id,
)
else:
# Validate conversation exists, create if missing (handles deleted conversations)
import time
# Then yield the workflow event
yield event
existing = self.conversation_store.get_conversation(conversation_id)
if not existing:
logger.warning(f"Conversation {conversation_id} not found (may have been deleted), recreating")
self.conversation_store.create_conversation(
metadata={
"entity_id": entity_id,
"type": "workflow_session",
"created_at": str(int(time.time())),
},
conversation_id=conversation_id,
)
# Get session-scoped checkpoint storage (InMemoryCheckpointStorage from conv_data)
# Each conversation has its own storage instance, providing automatic session isolation.
# This storage is passed to workflow.run_stream() which sets it as runtime override,
# ensuring all checkpoint operations (save/load) use THIS conversation's storage.
# The framework guarantees runtime storage takes precedence over build-time storage.
checkpoint_storage = self.checkpoint_manager.get_checkpoint_storage(conversation_id)
# Check for HIL responses first
hil_responses = self._extract_workflow_hil_responses(request.input)
# Determine checkpoint_id (explicit or auto-latest for HIL responses)
checkpoint_id = None
if request.extra_body and "checkpoint_id" in request.extra_body:
checkpoint_id = request.extra_body["checkpoint_id"]
logger.debug(f"Using explicit checkpoint_id from request: {checkpoint_id}")
elif hil_responses:
# Only auto-resume from latest checkpoint when we have HIL responses
# Regular "Run" clicks should start fresh, not resume from checkpoints
checkpoints = await checkpoint_storage.list_checkpoints() # No workflow_id filter needed!
if checkpoints:
latest = max(checkpoints, key=lambda cp: cp.timestamp)
checkpoint_id = latest.checkpoint_id
logger.info(f"Auto-resuming from latest checkpoint in session {conversation_id}: {checkpoint_id}")
else:
logger.warning(f"HIL responses received but no checkpoints in session {conversation_id}")
if hil_responses:
# HIL continuation mode requires checkpointing
if not checkpoint_id:
error_msg = (
"Cannot process HIL responses without a checkpoint. "
"Workflows using HIL must be configured with .with_checkpointing() "
"and a checkpoint must exist before sending responses."
)
logger.error(error_msg)
yield {"type": "error", "message": error_msg}
return
logger.info(f"Resuming workflow with HIL responses for {len(hil_responses)} request(s)")
# Unwrap primitive responses if they're wrapped in {response: value} format
from ._utils import parse_input_for_type
unwrapped_responses = {}
for request_id, response_value in hil_responses.items():
if isinstance(response_value, dict) and "response" in response_value:
response_value = response_value["response"]
unwrapped_responses[request_id] = response_value
hil_responses = unwrapped_responses
# NOTE: Two-step approach for stateless HTTP (framework limitation):
# 1. Restore checkpoint to load pending requests into workflow's in-memory state
# 2. Then send responses using send_responses_streaming
# Future: Framework should support run_stream(checkpoint_id, responses) in single call
# (checkpoint_id is guaranteed to exist due to earlier validation)
logger.debug(f"Restoring checkpoint {checkpoint_id} then sending HIL responses")
try:
# Step 1: Restore checkpoint to populate workflow's in-memory pending requests
restored = False
async for _event in workflow.run_stream(
checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage
):
restored = True
break # Stop immediately after restoration, don't process events
if not restored:
raise RuntimeError("Checkpoint restoration did not yield any events")
# Reset running flags so we can call send_responses_streaming
if hasattr(workflow, "_is_running"):
workflow._is_running = False
if hasattr(workflow, "_runner") and hasattr(workflow._runner, "_running"):
workflow._runner._running = False
# Extract response types from restored workflow and convert responses to proper types
try:
if hasattr(workflow, "_runner") and hasattr(workflow._runner, "context"):
runner_context = workflow._runner.context
pending_requests_dict = await runner_context.get_pending_request_info_events()
converted_responses = {}
for request_id, response_value in hil_responses.items():
if request_id in pending_requests_dict:
pending_request = pending_requests_dict[request_id]
if hasattr(pending_request, "response_type"):
response_type = pending_request.response_type
try:
response_value = parse_input_for_type(response_value, response_type)
logger.debug(
f"Converted HIL response for {request_id} to {type(response_value)}"
)
except Exception as e:
logger.warning(f"Failed to convert HIL response for {request_id}: {e}")
converted_responses[request_id] = response_value
hil_responses = converted_responses
except Exception as e:
logger.warning(f"Could not convert HIL responses to proper types: {e}")
# Step 2: Now send responses to the in-memory workflow
async for event in workflow.send_responses_streaming(hil_responses):
for trace_event in trace_collector.get_pending_events():
yield trace_event
yield event
except (AttributeError, ValueError, RuntimeError) as e:
error_msg = f"Failed to send HIL responses: {e}"
logger.error(error_msg)
yield {"type": "error", "message": error_msg}
elif checkpoint_id:
# Resume from checkpoint (explicit or auto-latest) using unified API
logger.info(f"Resuming workflow from checkpoint {checkpoint_id} in session {conversation_id}")
try:
async for event in workflow.run_stream(
checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage
):
if isinstance(event, RequestInfoEvent):
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
yield trace_event
yield event
# Note: Removed break on RequestInfoEvent - continue yielding all events
# The workflow is already paused by ctx.request_info() in the framework
# DevUI should continue yielding events even during HIL pause
except ValueError as e:
error_msg = f"Cannot resume from checkpoint: {e}"
logger.error(error_msg)
yield {"type": "error", "message": error_msg}
else:
# First run - pass DevUI's checkpoint storage to enable checkpointing
logger.info(f"Starting fresh workflow in session {conversation_id}")
parsed_input = await self._parse_workflow_input(workflow, request.input)
async for event in workflow.run_stream(parsed_input, checkpoint_storage=checkpoint_storage):
if isinstance(event, RequestInfoEvent):
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
yield trace_event
yield event
# Note: Removed break on RequestInfoEvent - continue yielding all events
# The workflow is already paused by ctx.request_info() in the framework
# DevUI should continue yielding events even during HIL pause
except Exception as e:
logger.error(f"Error in workflow execution: {e}")
@@ -569,6 +770,59 @@ class AgentFrameworkExecutor:
return start_executor, message_types
def _extract_workflow_hil_responses(self, input_data: Any) -> dict[str, Any] | None:
"""Extract workflow HIL responses from OpenAI input format.
Looks for special content type: workflow_hil_response
Args:
input_data: OpenAI ResponseInputParam
Returns:
Dict of {request_id: response_value} if found, None otherwise
"""
if not isinstance(input_data, list):
return None
for item in input_data:
if isinstance(item, dict) and item.get("type") == "message":
message_content = item.get("content", [])
if isinstance(message_content, list):
for content_item in message_content:
if isinstance(content_item, dict):
content_type = content_item.get("type")
if content_type == "workflow_hil_response":
# Extract responses dict
# dict.get() returns Any, so we explicitly type it
responses: dict[str, Any] = content_item.get("responses", {}) # type: ignore[assignment]
logger.info(f"Found workflow HIL responses: {list(responses.keys())}")
return responses
return None
def _get_or_create_conversation(self, conversation_id: str, entity_id: str) -> Any:
"""Get existing conversation or create a new one.
Args:
conversation_id: Conversation ID from frontend
entity_id: Entity ID (e.g., "spam_workflow") for metadata filtering
Returns:
Conversation object
"""
conversation = self.conversation_store.get_conversation(conversation_id)
if not conversation:
# Create conversation with frontend's ID
# Use agent_id in metadata so it can be filtered by list_conversations(agent_id=...)
conversation = self.conversation_store.create_conversation(
metadata={"agent_id": entity_id}, conversation_id=conversation_id
)
logger.info(f"Created conversation {conversation_id} for entity {entity_id}")
return conversation
def _parse_structured_workflow_input(self, workflow: Any, input_data: dict[str, Any]) -> Any:
"""Parse structured input data for workflow execution.
@@ -644,3 +898,53 @@ class AgentFrameworkExecutor:
except Exception as e:
logger.debug(f"Error parsing workflow input: {e}")
return raw_input
def _enrich_request_info_event_with_response_schema(self, event: Any, workflow: Any) -> None:
"""Extract response type from workflow executor and attach response schema to RequestInfoEvent.
Args:
event: RequestInfoEvent to enrich
workflow: Workflow object containing executors
"""
try:
from agent_framework_devui._utils import extract_response_type_from_executor, generate_input_schema
# Get source executor ID and request type from event
source_executor_id = getattr(event, "source_executor_id", None)
request_type = getattr(event, "request_type", None)
if not source_executor_id or not request_type:
logger.debug("RequestInfoEvent missing source_executor_id or request_type")
return
# Find the source executor in the workflow
if not hasattr(workflow, "executors") or not isinstance(workflow.executors, dict):
logger.debug("Workflow doesn't have executors dict")
return
source_executor = workflow.executors.get(source_executor_id)
if not source_executor:
logger.debug(f"Could not find executor '{source_executor_id}' in workflow")
return
# Extract response type from the executor's handler signature
response_type = extract_response_type_from_executor(source_executor, request_type)
if response_type:
# Generate JSON schema for response type
response_schema = generate_input_schema(response_type)
# Attach response_schema to event for mapper to include in output
event._response_schema = response_schema
logger.debug(f"Extracted response schema for {request_type.__name__}: {response_schema}")
else:
# Even if extraction fails, provide a reasonable default to avoid warnings
logger.debug(
f"Could not extract response type for {request_type.__name__}, using default string schema"
)
response_schema = {"type": "string"}
event._response_schema = response_schema
except Exception as e:
logger.warning(f"Failed to enrich RequestInfoEvent with response schema: {e}")
@@ -34,6 +34,9 @@ from .models import (
ResponseFunctionCallArgumentsDeltaEvent,
ResponseFunctionResultComplete,
ResponseFunctionToolCall,
ResponseOutputData,
ResponseOutputFile,
ResponseOutputImage,
ResponseOutputItemAddedEvent,
ResponseOutputMessage,
ResponseOutputText,
@@ -160,7 +163,7 @@ class MessageMapper:
if isinstance(raw_event, ResponseTraceEvent):
return [
ResponseTraceEventComplete(
type="response.trace.complete",
type="response.trace.completed",
data=raw_event.data,
item_id=context["item_id"],
sequence_number=self._next_sequence(context),
@@ -273,7 +276,7 @@ class MessageMapper:
id=f"resp_{uuid.uuid4().hex[:12]}",
object="response",
created_at=datetime.now().timestamp(),
model=request.model,
model=request.model or "devui",
output=[response_output_message],
usage=usage,
parallel_tool_calls=False,
@@ -338,6 +341,147 @@ class MessageMapper:
context["sequence_counter"] += 1
return int(context["sequence_counter"])
def _serialize_value(self, value: Any) -> Any:
"""Recursively serialize a value, handling complex nested objects.
Handles:
- Primitives (str, int, float, bool, None)
- Collections (list, tuple, set, dict)
- SerializationMixin objects (ChatMessage, etc.) - calls to_dict()
- Pydantic models - calls model_dump()
- Dataclasses - recursively serializes with asdict()
- Enums - extracts value
- datetime/date/UUID - converts to ISO string
Args:
value: Value to serialize
Returns:
JSON-serializable representation
"""
from dataclasses import is_dataclass
from datetime import date, datetime
from enum import Enum
from uuid import UUID
# Handle None
if value is None:
return None
# Handle primitives
if isinstance(value, (str, int, float, bool)):
return value
# Handle datetime/date - convert to ISO format
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
# Handle UUID - convert to string
if isinstance(value, UUID):
return str(value)
# Handle Enums - extract value
if isinstance(value, Enum):
return value.value
# Handle lists/tuples/sets - recursively serialize elements
if isinstance(value, (list, tuple)):
return [self._serialize_value(item) for item in value]
if isinstance(value, set):
return [self._serialize_value(item) for item in value]
# Handle dicts - recursively serialize values
if isinstance(value, dict):
return {k: self._serialize_value(v) for k, v in value.items()}
# Handle SerializationMixin (like ChatMessage) - call to_dict()
if hasattr(value, "to_dict") and callable(getattr(value, "to_dict", None)):
try:
return value.to_dict() # type: ignore[attr-defined, no-any-return]
except Exception as e:
logger.debug(f"Failed to serialize with to_dict(): {e}")
return str(value)
# Handle Pydantic models - call model_dump()
if hasattr(value, "model_dump") and callable(getattr(value, "model_dump", None)):
try:
return value.model_dump() # type: ignore[attr-defined, no-any-return]
except Exception as e:
logger.debug(f"Failed to serialize Pydantic model: {e}")
return str(value)
# Handle dataclasses - recursively serialize with asdict
if is_dataclass(value) and not isinstance(value, type):
try:
from dataclasses import asdict
# Use our custom serializer as dict_factory
return asdict(value, dict_factory=lambda items: {k: self._serialize_value(v) for k, v in items})
except Exception as e:
logger.debug(f"Failed to serialize nested dataclass: {e}")
return str(value)
# Fallback: convert to string (for unknown types)
logger.debug(f"Serializing unknown type {type(value).__name__} as string")
return str(value)
def _serialize_request_data(self, request_data: Any) -> dict[str, Any]:
"""Serialize RequestInfoMessage to dict for JSON transmission.
Handles nested SerializationMixin objects (like ChatMessage) within dataclasses.
Args:
request_data: The RequestInfoMessage instance
Returns:
Serialized dict representation
"""
from dataclasses import asdict, fields, is_dataclass
if request_data is None:
return {}
# Handle dict first (most common)
if isinstance(request_data, dict):
return {k: self._serialize_value(v) for k, v in request_data.items()}
# Handle dataclasses with nested SerializationMixin objects
# We can't use asdict() directly because it doesn't handle ChatMessage
if is_dataclass(request_data) and not isinstance(request_data, type):
try:
# Manually serialize each field to handle nested SerializationMixin
result = {}
for field in fields(request_data):
field_value = getattr(request_data, field.name)
result[field.name] = self._serialize_value(field_value)
return result
except Exception as e:
logger.debug(f"Failed to serialize dataclass fields: {e}")
# Fallback to asdict() if our custom serialization fails
try:
return asdict(request_data) # type: ignore[arg-type]
except Exception as e2:
logger.debug(f"Failed to serialize dataclass with asdict(): {e2}")
# Handle Pydantic models (have model_dump method)
if hasattr(request_data, "model_dump") and callable(getattr(request_data, "model_dump", None)):
try:
return request_data.model_dump() # type: ignore[attr-defined, no-any-return]
except Exception as e:
logger.debug(f"Failed to serialize Pydantic model: {e}")
# Handle SerializationMixin (have to_dict method)
if hasattr(request_data, "to_dict") and callable(getattr(request_data, "to_dict", None)):
try:
return request_data.to_dict() # type: ignore[attr-defined, no-any-return]
except Exception as e:
logger.debug(f"Failed to serialize with to_dict(): {e}")
# Fallback: string representation
return {"raw": str(request_data)}
async def _convert_agent_update(self, update: Any, context: dict[str, Any]) -> Sequence[Any]:
"""Convert agent text updates to proper content part events.
@@ -495,8 +639,9 @@ class MessageMapper:
from .models._openai_custom import AgentCompletedEvent, AgentFailedEvent, AgentStartedEvent
try:
# Get model name from context (the agent name)
model_name = context.get("request", {}).model if context.get("request") else "agent"
# Get model name from request or use 'devui' as default
request_obj = context.get("request")
model_name = request_obj.model if request_obj and request_obj.model else "devui"
if isinstance(event, AgentStartedEvent):
execution_id = f"agent_{uuid4().hex[:12]}"
@@ -603,16 +748,16 @@ class MessageMapper:
# Return proper OpenAI event objects
events: list[Any] = []
# Determine the model name - use request model or default to "workflow"
# The request model will be the agent name for agents, workflow name for workflows
model_name = context.get("request", {}).model if context.get("request") else "workflow"
# Get model name from request or use 'devui' as default
request_obj = context.get("request")
model_name = request_obj.model if request_obj and request_obj.model else "devui"
# Create a full Response object with all required fields
response_obj = Response(
id=f"resp_{workflow_id}",
object="response",
created_at=float(time.time()),
model=model_name, # Use the actual model/agent name
model=model_name,
output=[], # Empty output list initially
status="in_progress",
# Required fields with safe defaults
@@ -637,14 +782,73 @@ class MessageMapper:
return events
if event_class in ["WorkflowCompletedEvent", "WorkflowOutputEvent"]:
# Handle WorkflowOutputEvent separately to preserve output data
if event_class == "WorkflowOutputEvent":
output_data = getattr(event, "data", None)
source_executor_id = getattr(event, "source_executor_id", "unknown")
if output_data is not None:
# Import required types
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
# Increment output index for each yield_output
context["output_index"] = context.get("output_index", -1) + 1
# Extract text from output data based on type
text = None
if hasattr(output_data, "__class__") and output_data.__class__.__name__ == "ChatMessage":
# Handle ChatMessage (from Magentic and AgentExecutor with output_response=True)
text = getattr(output_data, "text", None)
if not text:
# Fallback to string representation
text = str(output_data)
elif isinstance(output_data, str):
# String output
text = output_data
else:
# Object/dict/list → JSON string
try:
text = json.dumps(output_data, indent=2)
except (TypeError, ValueError):
# Fallback to string representation if not JSON serializable
text = str(output_data)
# Create output message with text content
text_content = ResponseOutputText(type="output_text", text=text, annotations=[])
output_message = ResponseOutputMessage(
type="message",
id=f"msg_{uuid4().hex[:8]}",
role="assistant",
content=[text_content],
status="completed",
)
# Emit output_item.added for each yield_output
logger.debug(
f"WorkflowOutputEvent converted to output_item.added "
f"(executor: {source_executor_id}, length: {len(text)})"
)
return [
ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=output_message,
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
]
# Handle WorkflowCompletedEvent - emit response.completed
if event_class == "WorkflowCompletedEvent":
workflow_id = context.get("workflow_id", str(uuid4()))
# Import Response type for proper construction
from openai.types.responses import Response
# Get model name from context
model_name = context.get("request", {}).model if context.get("request") else "workflow"
# Get model name from request or use 'devui' as default
request_obj = context.get("request")
model_name = request_obj.model if request_obj and request_obj.model else "devui"
# Create a full Response object for completed state
response_obj = Response(
@@ -652,7 +856,7 @@ class MessageMapper:
object="response",
created_at=float(time.time()),
model=model_name,
output=[], # Output should be populated by this point from text streaming
output=[], # Output items already sent via output_item.added events
status="completed",
parallel_tool_calls=False,
tool_choice="none",
@@ -672,8 +876,9 @@ class MessageMapper:
# Import Response and ResponseError types
from openai.types.responses import Response, ResponseError
# Get model name from context
model_name = context.get("request", {}).model if context.get("request") else "workflow"
# Get model name from request or use 'devui' as default
request_obj = context.get("request")
model_name = request_obj.model if request_obj and request_obj.model else "devui"
# Create error object
error_message = str(error_info) if error_info else "Unknown error"
@@ -778,8 +983,77 @@ class MessageMapper:
)
]
# Handle informational workflow events (status, warnings, errors)
if event_class in ["WorkflowStatusEvent", "WorkflowWarningEvent", "WorkflowErrorEvent", "RequestInfoEvent"]:
# Handle RequestInfoEvent specially - emit as HIL event with schema
if event_class == "RequestInfoEvent":
from .models._openai_custom import ResponseRequestInfoEvent
request_id = getattr(event, "request_id", "")
source_executor_id = getattr(event, "source_executor_id", "")
request_type_class = getattr(event, "request_type", None)
request_data = getattr(event, "data", None)
logger.info("📨 [MAPPER] Processing RequestInfoEvent")
logger.info(f" request_id: {request_id}")
logger.info(f" source_executor_id: {source_executor_id}")
logger.info(f" request_type_class: {request_type_class}")
logger.info(f" request_data: {request_data}")
# Serialize request data
serialized_data = self._serialize_request_data(request_data)
logger.info(f" serialized_data: {serialized_data}")
# Get request type name for debugging
request_type_name = "Unknown"
if request_type_class:
request_type_name = f"{request_type_class.__module__}:{request_type_class.__name__}"
# Get response schema that was attached by executor
# This tells the UI what format to collect from the user
response_schema = getattr(event, "_response_schema", None)
if not response_schema:
# Fallback to string if somehow not set (shouldn't happen with current executor enrichment)
logger.warning(f"⚠️ Response schema not found for {request_type_name}, using default")
response_schema = {"type": "string"}
else:
logger.info(f" response_schema: {response_schema}")
# Wrap primitive schemas in object for form rendering
# The UI's SchemaFormRenderer expects an object with properties
if response_schema.get("type") in ["string", "integer", "number", "boolean"]:
# Wrap primitive type in object with "response" field
wrapped_schema = {
"type": "object",
"properties": {"response": response_schema},
"required": ["response"],
}
logger.info(" wrapped primitive schema in object")
else:
wrapped_schema = response_schema
# Create HIL request event with response schema
hil_event = ResponseRequestInfoEvent(
type="response.request_info.requested",
request_id=request_id,
source_executor_id=source_executor_id,
request_type=request_type_name,
request_data=serialized_data,
request_schema=wrapped_schema, # Send wrapped schema for form rendering
response_schema=response_schema, # Keep original for reference
item_id=context["item_id"],
output_index=context.get("output_index", 0),
sequence_number=self._next_sequence(context),
timestamp=datetime.now().isoformat(),
)
logger.info("✅ [MAPPER] Created ResponseRequestInfoEvent:")
logger.info(f" type: {hil_event.type}")
logger.info(f" request_id: {hil_event.request_id}")
logger.info(f" sequence_number: {hil_event.sequence_number}")
return [hil_event]
# Handle other informational workflow events (status, warnings, errors)
if event_class in ["WorkflowStatusEvent", "WorkflowWarningEvent", "WorkflowErrorEvent"]:
# These are informational events that don't map to OpenAI lifecycle events
# Convert them to trace events for debugging visibility
event_data: dict[str, Any] = {}
@@ -792,13 +1066,10 @@ class MessageMapper:
elif event_class == "WorkflowErrorEvent":
event_data["message"] = str(getattr(event, "message", ""))
event_data["error"] = str(getattr(event, "error", ""))
elif event_class == "RequestInfoEvent":
request_info = getattr(event, "data", {})
event_data["request_info"] = request_info if isinstance(request_info, dict) else str(request_info)
# Create a trace event for debugging
trace_event = ResponseTraceEventComplete(
type="response.trace.complete",
type="response.trace.completed",
data={
"trace_type": "workflow_info",
"event_type": event_class,
@@ -813,6 +1084,237 @@ class MessageMapper:
return [trace_event]
# Handle Magentic-specific events
if event_class == "MagenticAgentDeltaEvent":
agent_id = getattr(event, "agent_id", "unknown_agent")
text = getattr(event, "text", None)
if text:
events = []
# Track Magentic agent messages separately from regular messages
# Use timestamp to ensure uniqueness for multiple runs of same agent
magentic_key = f"magentic_message_{agent_id}"
# Check if this is the first delta from this agent (need to create message container)
if magentic_key not in context:
# Create a unique message ID for this agent's streaming session
message_id = f"msg_{agent_id}_{uuid4().hex[:8]}"
context[magentic_key] = message_id
context["output_index"] = context.get("output_index", -1) + 1
# Import required types
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from openai.types.responses.response_content_part_added_event import (
ResponseContentPartAddedEvent,
)
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
# Emit message output item (container for the agent's message)
# This matches what _convert_agent_update does for regular agents
events.append(
ResponseOutputItemAddedEvent(
type="response.output_item.added",
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
item=ResponseOutputMessage(
type="message",
id=message_id,
role="assistant",
content=[],
status="in_progress",
# Add metadata to identify this as a Magentic agent message
metadata={"agent_id": agent_id, "source": "magentic"}, # type: ignore[call-arg]
),
)
)
# Add content part for text (establishes the text container)
events.append(
ResponseContentPartAddedEvent(
type="response.content_part.added",
output_index=context["output_index"],
content_index=0,
item_id=message_id,
sequence_number=self._next_sequence(context),
part=ResponseOutputText(type="output_text", text="", annotations=[]),
)
)
# Get the message ID for this agent
message_id = context[magentic_key]
# Emit text delta event using the message ID (matches regular agent behavior)
events.append(
ResponseTextDeltaEvent(
type="response.output_text.delta",
output_index=context["output_index"],
content_index=0, # Always 0 for single text content
item_id=message_id,
delta=text,
logprobs=[],
sequence_number=self._next_sequence(context),
)
)
return events
# Handle function calls from Magentic agents
if getattr(event, "function_call_id", None) and getattr(event, "function_call_name", None):
# Handle function call initiation
function_call_id = getattr(event, "function_call_id", None)
function_call_name = getattr(event, "function_call_name", None)
function_call_arguments = getattr(event, "function_call_arguments", None)
# Track function call for accumulating arguments
context["active_function_calls"][function_call_id] = {
"item_id": function_call_id,
"name": function_call_name,
"arguments_chunks": [],
}
# Emit function call output item
return [
ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseFunctionToolCall(
id=function_call_id,
call_id=function_call_id,
name=function_call_name,
arguments=json.dumps(function_call_arguments) if function_call_arguments else "",
type="function_call",
status="in_progress",
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
]
# For other non-text deltas, emit as trace for debugging
return [
ResponseTraceEventComplete(
type="response.trace.completed",
data={
"trace_type": "magentic_delta",
"agent_id": agent_id,
"function_call_id": getattr(event, "function_call_id", None),
"function_call_name": getattr(event, "function_call_name", None),
"function_result_id": getattr(event, "function_result_id", None),
"timestamp": datetime.now().isoformat(),
},
span_id=f"magentic_delta_{uuid4().hex[:8]}",
item_id=context["item_id"],
output_index=context.get("output_index", 0),
sequence_number=self._next_sequence(context),
)
]
if event_class == "MagenticAgentMessageEvent":
agent_id = getattr(event, "agent_id", "unknown_agent")
message = getattr(event, "message", None)
# Track Magentic agent messages
magentic_key = f"magentic_message_{agent_id}"
# Check if we were streaming for this agent
if magentic_key in context:
# Mark the streaming message as complete
message_id = context[magentic_key]
# Import required types
from openai.types.responses import ResponseOutputMessage
from openai.types.responses.response_output_item_done_event import ResponseOutputItemDoneEvent
# Extract text from ChatMessage for the completed message
text = None
if message and hasattr(message, "text"):
text = message.text
# Emit output_item.done to mark message as complete
events = [
ResponseOutputItemDoneEvent(
type="response.output_item.done",
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
item=ResponseOutputMessage(
type="message",
id=message_id,
role="assistant",
content=[], # Content already streamed via deltas
status="completed",
metadata={"agent_id": agent_id, "source": "magentic"}, # type: ignore[call-arg]
),
)
]
# Clean up context for this agent
del context[magentic_key]
logger.debug(f"MagenticAgentMessageEvent from {agent_id} marked streaming message as complete")
return events
# No streaming occurred, create a complete message (shouldn't happen normally)
# Extract text from ChatMessage
text = None
if message and hasattr(message, "text"):
text = message.text
if text:
# Emit as output item for this agent
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
context["output_index"] = context.get("output_index", -1) + 1
text_content = ResponseOutputText(type="output_text", text=text, annotations=[])
output_message = ResponseOutputMessage(
type="message",
id=f"msg_{agent_id}_{uuid4().hex[:8]}",
role="assistant",
content=[text_content],
status="completed",
metadata={"agent_id": agent_id, "source": "magentic"}, # type: ignore[call-arg]
)
logger.debug(
f"MagenticAgentMessageEvent from {agent_id} converted to output_item.added (non-streaming)"
)
return [
ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=output_message,
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
]
if event_class == "MagenticOrchestratorMessageEvent":
orchestrator_id = getattr(event, "orchestrator_id", "orchestrator")
message = getattr(event, "message", None)
kind = getattr(event, "kind", "unknown")
# Extract text from ChatMessage
text = None
if message and hasattr(message, "text"):
text = message.text
# Emit as trace event for orchestrator messages (typically task ledger, instructions)
return [
ResponseTraceEventComplete(
type="response.trace.completed",
data={
"trace_type": "magentic_orchestrator",
"orchestrator_id": orchestrator_id,
"kind": kind,
"text": text or str(message),
"timestamp": datetime.now().isoformat(),
},
span_id=f"magentic_orch_{uuid4().hex[:8]}",
item_id=context["item_id"],
output_index=context.get("output_index", 0),
sequence_number=self._next_sequence(context),
)
]
# For unknown/legacy events, still emit as workflow event for backward compatibility
# Get event data and serialize if it's a SerializationMixin
raw_event_data = getattr(event, "data", None)
@@ -827,7 +1329,7 @@ class MessageMapper:
# Create structured workflow event (keeping for backward compatibility)
workflow_event = ResponseWorkflowEventComplete(
type="response.workflow_event.complete",
type="response.workflow_event.completed",
data={
"event_type": event.__class__.__name__,
"data": serialized_event_data,
@@ -1053,30 +1555,227 @@ class MessageMapper:
# NO EVENT RETURNED - usage goes in final Response only
return
async def _map_data_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
"""Map DataContent to structured trace event."""
return ResponseTraceEventComplete(
type="response.trace.complete",
data={
"content_type": "data",
"data": getattr(content, "data", None),
"mime_type": getattr(content, "mime_type", "application/octet-stream"),
"size_bytes": len(str(getattr(content, "data", ""))) if getattr(content, "data", None) else 0,
"timestamp": datetime.now().isoformat(),
},
item_id=context["item_id"],
async def _map_data_content(
self, content: Any, context: dict[str, Any]
) -> ResponseOutputItemAddedEvent | ResponseTraceEventComplete:
"""Map DataContent to proper output item (image/file/data) or fallback to trace.
Maps Agent Framework DataContent to appropriate output types:
- Images (image/*) ResponseOutputImage
- Common files (pdf, audio, video) ResponseOutputFile
- Generic data ResponseOutputData
- Unknown/debugging content ResponseTraceEventComplete (fallback)
"""
mime_type = getattr(content, "mime_type", "application/octet-stream")
item_id = f"item_{uuid.uuid4().hex[:16]}"
# Extract data/uri
data_value = getattr(content, "data", None)
uri_value = getattr(content, "uri", None)
# Handle images
if mime_type.startswith("image/"):
# Prefer URI, but create data URI from data if needed
if uri_value:
image_url = uri_value
elif data_value:
# Convert bytes to base64 data URI
import base64
if isinstance(data_value, bytes):
b64_data = base64.b64encode(data_value).decode("utf-8")
else:
b64_data = str(data_value)
image_url = f"data:{mime_type};base64,{b64_data}"
else:
# No data available, fallback to trace
logger.warning(f"DataContent with {mime_type} has no data or uri, falling back to trace")
return ResponseTraceEventComplete(
type="response.trace.completed",
data={"content_type": "data", "mime_type": mime_type, "error": "No data or uri"},
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
return ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputImage( # type: ignore[arg-type]
id=item_id,
type="output_image",
image_url=image_url,
mime_type=mime_type,
alt_text=None,
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
# Handle common file types
if mime_type in [
"application/pdf",
"audio/mp3",
"audio/wav",
"audio/m4a",
"audio/ogg",
"audio/flac",
"audio/aac",
"audio/mpeg",
"video/mp4",
"video/webm",
]:
# Determine filename from mime type
ext = mime_type.split("/")[-1]
if ext == "mpeg":
ext = "mp3" # audio/mpeg → .mp3
filename = f"output.{ext}"
# Prefer URI
if uri_value:
file_url = uri_value
file_data = None
elif data_value:
# Convert bytes to base64
import base64
if isinstance(data_value, bytes):
b64_data = base64.b64encode(data_value).decode("utf-8")
else:
b64_data = str(data_value)
file_url = f"data:{mime_type};base64,{b64_data}"
file_data = b64_data
else:
# No data available, fallback to trace
logger.warning(f"DataContent with {mime_type} has no data or uri, falling back to trace")
return ResponseTraceEventComplete(
type="response.trace.completed",
data={"content_type": "data", "mime_type": mime_type, "error": "No data or uri"},
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
return ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputFile( # type: ignore[arg-type]
id=item_id,
type="output_file",
filename=filename,
file_url=file_url,
file_data=file_data,
mime_type=mime_type,
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
# Handle generic data (structured data, JSON, etc.)
data_str = ""
if uri_value:
data_str = uri_value
elif data_value:
if isinstance(data_value, bytes):
try:
data_str = data_value.decode("utf-8")
except UnicodeDecodeError:
# Binary data, encode as base64 for display
import base64
data_str = base64.b64encode(data_value).decode("utf-8")
else:
data_str = str(data_value)
return ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputData( # type: ignore[arg-type]
id=item_id,
type="output_data",
data=data_str,
mime_type=mime_type,
description=None,
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
async def _map_uri_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
"""Map UriContent to structured trace event."""
async def _map_uri_content(
self, content: Any, context: dict[str, Any]
) -> ResponseOutputItemAddedEvent | ResponseTraceEventComplete:
"""Map UriContent to proper output item (image/file) based on MIME type.
UriContent has a URI and MIME type, so we can create appropriate output items:
- Images ResponseOutputImage
- Common files ResponseOutputFile
- Other URIs ResponseTraceEventComplete (fallback for debugging)
"""
mime_type = getattr(content, "mime_type", "text/plain")
uri = getattr(content, "uri", "")
item_id = f"item_{uuid.uuid4().hex[:16]}"
if not uri:
# No URI available, fallback to trace
logger.warning("UriContent has no uri, falling back to trace")
return ResponseTraceEventComplete(
type="response.trace.completed",
data={"content_type": "uri", "mime_type": mime_type, "error": "No uri"},
item_id=context["item_id"],
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
# Handle images
if mime_type.startswith("image/"):
return ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputImage( # type: ignore[arg-type]
id=item_id,
type="output_image",
image_url=uri,
mime_type=mime_type,
alt_text=None,
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
# Handle common file types
if mime_type in [
"application/pdf",
"audio/mp3",
"audio/wav",
"audio/m4a",
"audio/ogg",
"audio/flac",
"audio/aac",
"audio/mpeg",
"video/mp4",
"video/webm",
]:
# Extract filename from URI or use generic name
filename = uri.split("/")[-1] if "/" in uri else f"output.{mime_type.split('/')[-1]}"
return ResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputFile( # type: ignore[arg-type]
id=item_id,
type="output_file",
filename=filename,
file_url=uri,
file_data=None,
mime_type=mime_type,
),
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
)
# For other URI types (text/plain, application/json, etc.), use trace for now
logger.debug(f"UriContent with unsupported MIME type {mime_type}, using trace event")
return ResponseTraceEventComplete(
type="response.trace.complete",
type="response.trace.completed",
data={
"content_type": "uri",
"uri": getattr(content, "uri", ""),
"mime_type": getattr(content, "mime_type", "text/plain"),
"uri": uri,
"mime_type": mime_type,
"timestamp": datetime.now().isoformat(),
},
item_id=context["item_id"],
@@ -1085,9 +1784,15 @@ class MessageMapper:
)
async def _map_hosted_file_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
"""Map HostedFileContent to structured trace event."""
"""Map HostedFileContent to trace event.
HostedFileContent references external file IDs (like OpenAI file IDs).
These remain as traces since they're metadata about hosted resources,
not direct content to display. To display them, agents should return
DataContent or UriContent with the actual file data/URL.
"""
return ResponseTraceEventComplete(
type="response.trace.complete",
type="response.trace.completed",
data={
"content_type": "hosted_file",
"file_id": getattr(content, "file_id", "unknown"),
@@ -1101,9 +1806,14 @@ class MessageMapper:
async def _map_hosted_vector_store_content(
self, content: Any, context: dict[str, Any]
) -> ResponseTraceEventComplete:
"""Map HostedVectorStoreContent to structured trace event."""
"""Map HostedVectorStoreContent to trace event.
HostedVectorStoreContent references external vector store IDs.
These remain as traces since they're metadata about hosted resources,
not direct content to display.
"""
return ResponseTraceEventComplete(
type="response.trace.complete",
type="response.trace.completed",
data={
"content_type": "hosted_vector_store",
"vector_store_id": getattr(content, "vector_store_id", "unknown"),
@@ -1208,7 +1918,7 @@ class MessageMapper:
id=f"resp_{uuid.uuid4().hex[:12]}",
object="response",
created_at=datetime.now().timestamp(),
model=request.model,
model=request.model or "devui",
output=[response_output_message],
usage=usage,
parallel_tool_calls=False,
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
"""OpenAI integration for DevUI - proxy support for OpenAI Responses API."""
from ._executor import OpenAIExecutor
__all__ = [
"OpenAIExecutor",
]
@@ -0,0 +1,270 @@
# Copyright (c) Microsoft. All rights reserved.
"""OpenAI Executor - proxies requests to OpenAI Responses API.
This executor mirrors the AgentFrameworkExecutor interface but routes
requests to OpenAI's API instead of executing local entities.
"""
import logging
import os
from collections.abc import AsyncGenerator
from typing import Any
from openai import APIStatusError, AsyncOpenAI, AsyncStream, AuthenticationError, PermissionDeniedError, RateLimitError
from openai.types.responses import Response, ResponseStreamEvent
from .._conversations import ConversationStore
from ..models import AgentFrameworkRequest, OpenAIResponse
logger = logging.getLogger(__name__)
class OpenAIExecutor:
"""Executor for OpenAI Responses API - mirrors AgentFrameworkExecutor interface.
This executor provides the same interface as AgentFrameworkExecutor but proxies
requests to OpenAI's Responses API instead of executing local entities.
Key features:
- Same execute_streaming() and execute_sync() interface
- Shares ConversationStore with local executor
- Configured via OPENAI_API_KEY environment variable
- Supports all OpenAI Responses API parameters
"""
def __init__(self, conversation_store: ConversationStore):
"""Initialize OpenAI executor.
Args:
conversation_store: Shared conversation store (works for both local and OpenAI)
"""
self.conversation_store = conversation_store
# Load configuration from environment
self.api_key = os.getenv("OPENAI_API_KEY")
self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
self._client: AsyncOpenAI | None = None
@property
def is_configured(self) -> bool:
"""Check if OpenAI executor is properly configured.
Returns:
True if OPENAI_API_KEY is set
"""
return self.api_key is not None
def _get_client(self) -> AsyncOpenAI:
"""Get or create OpenAI async client.
Returns:
AsyncOpenAI client instance
Raises:
ValueError: If OPENAI_API_KEY not configured
"""
if self._client is None:
if not self.api_key:
raise ValueError("OPENAI_API_KEY environment variable not set")
self._client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
)
logger.debug(f"Created OpenAI client with base_url: {self.base_url}")
return self._client
async def execute_streaming(self, request: AgentFrameworkRequest) -> AsyncGenerator[Any, None]:
"""Execute request via OpenAI and stream results in OpenAI format.
This mirrors AgentFrameworkExecutor.execute_streaming() interface.
Args:
request: Request to execute
Yields:
OpenAI ResponseStreamEvent objects (already in correct format!)
"""
if not self.is_configured:
logger.error("OpenAI executor not configured (missing OPENAI_API_KEY)")
# Emit proper response.failed event
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": "OpenAI not configured on server. Set OPENAI_API_KEY environment variable.",
"type": "configuration_error",
"code": "openai_not_configured",
},
},
}
return
try:
client = self._get_client()
# Convert AgentFrameworkRequest to OpenAI params
params = request.to_openai_params()
# Remove DevUI-specific fields that OpenAI doesn't recognize
params.pop("extra_body", None)
# Conversation ID is now from OpenAI (created via /v1/conversations proxy)
# so we can pass it through!
# Force streaming mode (remove if already present to avoid duplicate)
params.pop("stream", None)
logger.info(f"🔀 Proxying to OpenAI Responses API: model={params.get('model')}")
logger.debug(f"Request params: {params}")
# Call OpenAI Responses API - returns AsyncStream[ResponseStreamEvent]
stream: AsyncStream[ResponseStreamEvent] = await client.responses.create(
**params,
stream=True, # Force streaming
)
# Yield events directly - they're already ResponseStreamEvent objects!
# No conversion needed - OpenAI SDK returns proper typed objects
async for event in stream:
yield event
except AuthenticationError as e:
# 401 - Invalid API key or authentication issue
logger.error(f"OpenAI authentication error: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "authentication_error"),
"code": error_data.get("code", "invalid_api_key"),
},
},
}
except PermissionDeniedError as e:
# 403 - Permission denied
logger.error(f"OpenAI permission denied: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "permission_denied"),
"code": error_data.get("code", "insufficient_permissions"),
},
},
}
except RateLimitError as e:
# 429 - Rate limit exceeded
logger.error(f"OpenAI rate limit exceeded: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "rate_limit_error"),
"code": error_data.get("code", "rate_limit_exceeded"),
},
},
}
except APIStatusError as e:
# Other OpenAI API errors
logger.error(f"OpenAI API error: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "api_error"),
"code": error_data.get("code", "unknown_error"),
},
},
}
except Exception as e:
# Catch-all for unexpected errors
logger.error(f"Unexpected error in OpenAI proxy: {e}", exc_info=True)
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": f"Unexpected error: {e!s}",
"type": "internal_error",
"code": "unexpected_error",
},
},
}
async def execute_sync(self, request: AgentFrameworkRequest) -> OpenAIResponse:
"""Execute request via OpenAI and return complete response.
This mirrors AgentFrameworkExecutor.execute_sync() interface.
Args:
request: Request to execute
Returns:
Final OpenAI Response object
Raises:
ValueError: If OpenAI not configured
Exception: If OpenAI API call fails
"""
if not self.is_configured:
raise ValueError("OpenAI not configured on server. Set OPENAI_API_KEY environment variable.")
try:
client = self._get_client()
# Convert AgentFrameworkRequest to OpenAI params
params = request.to_openai_params()
# Remove DevUI-specific fields
params.pop("extra_body", None)
# Force non-streaming mode (remove if already present to avoid duplicate)
params.pop("stream", None)
logger.info(f"🔀 Proxying to OpenAI Responses API (non-streaming): model={params.get('model')}")
logger.debug(f"Request params: {params}")
# Call OpenAI Responses API - returns Response object
response: Response = await client.responses.create(
**params,
stream=False, # Force non-streaming
)
return response
except Exception as e:
logger.error(f"OpenAI proxy error: {e}", exc_info=True)
raise
async def close(self) -> None:
"""Close the OpenAI client and release resources."""
if self._client:
await self._client.close()
self._client = None
logger.debug("Closed OpenAI client")
@@ -5,7 +5,9 @@
import inspect
import json
import logging
from collections.abc import AsyncGenerator
import os
import secrets
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any
@@ -14,15 +16,20 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from ._deployment import DeploymentManager
from ._discovery import EntityDiscovery
from ._executor import AgentFrameworkExecutor
from ._mapper import MessageMapper
from .models import AgentFrameworkRequest, OpenAIError
from .models._discovery_models import DiscoveryResponse, EntityInfo
from ._openai import OpenAIExecutor
from .models import AgentFrameworkRequest, MetaResponse, OpenAIError
from .models._discovery_models import Deployment, DeploymentConfig, DiscoveryResponse, EntityInfo
logger = logging.getLogger(__name__)
# No AuthMiddleware class needed - we'll use the decorator pattern instead
class DevServer:
"""Development Server - OpenAI compatible API server for debugging agents."""
@@ -33,6 +40,7 @@ class DevServer:
host: str = "127.0.0.1",
cors_origins: list[str] | None = None,
ui_enabled: bool = True,
mode: str = "developer",
) -> None:
"""Initialize the development server.
@@ -42,16 +50,79 @@ class DevServer:
host: Host to bind server to
cors_origins: List of allowed CORS origins
ui_enabled: Whether to enable the UI
mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)
"""
self.entities_dir = entities_dir
self.port = port
self.host = host
self.cors_origins = cors_origins or ["*"]
# Smart CORS defaults: permissive for localhost, restrictive for network-exposed deployments
if cors_origins is None:
# Localhost development: allow cross-origin for dev tools (e.g., frontend dev server)
# Network-exposed: empty list (same-origin only, no CORS)
cors_origins = ["*"] if host in ("127.0.0.1", "localhost") else []
self.cors_origins = cors_origins
self.ui_enabled = ui_enabled
self.mode = mode
self.executor: AgentFrameworkExecutor | None = None
self.openai_executor: OpenAIExecutor | None = None
self.deployment_manager = DeploymentManager()
self._app: FastAPI | None = None
self._pending_entities: list[Any] | None = None
def _is_dev_mode(self) -> bool:
"""Check if running in developer mode.
Returns:
True if in developer mode, False if in user mode
"""
return self.mode == "developer"
def _format_error(self, error: Exception, context: str = "Operation") -> str:
"""Format error message based on server mode.
In developer mode: Returns detailed error message for debugging.
In user mode: Returns generic message and logs details internally.
Args:
error: The exception that occurred
context: Description of the operation that failed (e.g., "Request execution")
Returns:
Formatted error message appropriate for the current mode
"""
if self._is_dev_mode():
# Developer mode: Show full error details for debugging
return f"{context} failed: {error!s}"
# User mode: Generic message to user, detailed logging internally
logger.error(f"{context} failed: {error}", exc_info=True)
return f"{context} failed"
def _require_developer_mode(self, feature: str = "operation") -> None:
"""Check if current mode allows developer operations.
Args:
feature: Name of the feature being accessed (for error message)
Raises:
HTTPException: If in user mode
"""
if self.mode == "user":
logger.warning(f"Blocked {feature} access in user mode")
raise HTTPException(
status_code=403,
detail={
"error": {
"message": f"Access denied: {feature} requires developer mode",
"type": "permission_denied",
"code": "developer_mode_required",
"current_mode": self.mode,
}
},
)
async def _ensure_executor(self) -> AgentFrameworkExecutor:
"""Ensure executor is initialized."""
if self.executor is None:
@@ -84,6 +155,29 @@ class DevServer:
return self.executor
async def _ensure_openai_executor(self) -> OpenAIExecutor:
"""Ensure OpenAI executor is initialized.
Returns:
OpenAI executor instance
Raises:
ValueError: If OpenAI executor cannot be initialized
"""
if self.openai_executor is None:
# Initialize local executor first to get conversation_store
local_executor = await self._ensure_executor()
# Create OpenAI executor with shared conversation store
self.openai_executor = OpenAIExecutor(local_executor.conversation_store)
if self.openai_executor.is_configured:
logger.info("OpenAI proxy mode available (OPENAI_API_KEY configured)")
else:
logger.info("OpenAI proxy mode disabled (OPENAI_API_KEY not set)")
return self.openai_executor
async def _cleanup_entities(self) -> None:
"""Cleanup entity resources (close clients, MCP tools, credentials, etc.)."""
if not self.executor:
@@ -94,12 +188,28 @@ class DevServer:
closed_count = 0
mcp_tools_closed = 0
credentials_closed = 0
hook_count = 0
for entity_info in entities:
entity_id = entity_info.id
try:
entity_obj = self.executor.entity_discovery.get_entity_object(entity_info.id)
# Step 1: Execute registered cleanup hooks (NEW)
cleanup_hooks = self.executor.entity_discovery.get_cleanup_hooks(entity_id)
for hook in cleanup_hooks:
try:
if inspect.iscoroutinefunction(hook):
await hook()
else:
hook()
hook_count += 1
logger.debug(f"✓ Executed cleanup hook for: {entity_id}")
except Exception as e:
logger.warning(f"⚠ Cleanup hook failed for {entity_id}: {e}")
# Step 2: Close chat clients and their credentials (EXISTING)
entity_obj = self.executor.entity_discovery.get_entity_object(entity_id)
# Close chat clients and their credentials
if entity_obj and hasattr(entity_obj, "chat_client"):
client = entity_obj.chat_client
@@ -144,14 +254,24 @@ class DevServer:
logger.warning(f"Error closing MCP tool for {entity_info.id}: {e}")
except Exception as e:
logger.warning(f"Error closing entity {entity_info.id}: {e}")
logger.warning(f"Error cleaning up entity {entity_id}: {e}")
if hook_count > 0:
logger.info(f"✓ Executed {hook_count} cleanup hook(s)")
if closed_count > 0:
logger.info(f"Closed {closed_count} entity client(s)")
logger.info(f"Closed {closed_count} entity client(s)")
if credentials_closed > 0:
logger.info(f"Closed {credentials_closed} credential(s)")
logger.info(f"Closed {credentials_closed} credential(s)")
if mcp_tools_closed > 0:
logger.info(f"Closed {mcp_tools_closed} MCP tool(s)")
logger.info(f"Closed {mcp_tools_closed} MCP tool(s)")
# Close OpenAI executor if it exists
if self.openai_executor:
try:
await self.openai_executor.close()
logger.info("Closed OpenAI executor")
except Exception as e:
logger.warning(f"Error closing OpenAI executor: {e}")
def create_app(self) -> FastAPI:
"""Create the FastAPI application."""
@@ -161,6 +281,7 @@ class DevServer:
# Startup
logger.info("Starting Agent Framework Server")
await self._ensure_executor()
await self._ensure_openai_executor() # Initialize OpenAI executor
yield
# Shutdown
logger.info("Shutting down Agent Framework Server")
@@ -177,14 +298,74 @@ class DevServer:
)
# Add CORS middleware
# Note: allow_credentials cannot be True when allow_origins is ["*"]
# For localhost dev with wildcard origins, credentials are disabled
# For network deployments with specific origins or empty list, credentials can be enabled
allow_credentials = self.cors_origins != ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=self.cors_origins,
allow_credentials=True,
allow_credentials=allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
)
# Add authentication middleware using decorator pattern
# Auth is enabled by presence of DEVUI_AUTH_TOKEN
auth_token = os.getenv("DEVUI_AUTH_TOKEN", "")
auth_required = bool(auth_token)
if auth_required:
logger.info("Authentication middleware enabled")
@app.middleware("http")
async def auth_middleware(request: Request, call_next: Callable[[Request], Awaitable[Any]]) -> Any:
"""Validate Bearer token authentication.
Skips authentication for health, meta, static UI endpoints, and OPTIONS requests.
"""
# Skip auth for OPTIONS (CORS preflight) requests
if request.method == "OPTIONS":
return await call_next(request)
# Skip auth for health checks, meta endpoint, and static files
if request.url.path in ["/health", "/meta", "/"] or request.url.path.startswith("/assets"):
return await call_next(request)
# Check Authorization header
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return JSONResponse(
status_code=401,
content={
"error": {
"message": (
"Missing or invalid Authorization header. Expected: Authorization: Bearer <token>"
),
"type": "authentication_error",
"code": "missing_token",
}
},
)
# Extract and validate token
token = auth_header.replace("Bearer ", "", 1).strip()
if not secrets.compare_digest(token, auth_token):
return JSONResponse(
status_code=401,
content={
"error": {
"message": "Invalid authentication token",
"type": "authentication_error",
"code": "invalid_token",
}
},
)
# Token valid, proceed
return await call_next(request)
self._register_routes(app)
self._mount_ui(app)
@@ -202,6 +383,29 @@ class DevServer:
return {"status": "healthy", "entities_count": len(entities), "framework": "agent_framework"}
@app.get("/meta", response_model=MetaResponse)
async def get_meta() -> MetaResponse:
"""Get server metadata and configuration."""
import os
from . import __version__
# Ensure executors are initialized to check capabilities
openai_executor = await self._ensure_openai_executor()
return MetaResponse(
ui_mode=self.mode, # type: ignore[arg-type]
version=__version__,
framework="agent_framework",
runtime="python", # Python DevUI backend
capabilities={
"tracing": os.getenv("ENABLE_OTEL") == "true",
"openai_proxy": openai_executor.is_configured,
"deployment": True, # Deployment feature is available
},
auth_required=bool(os.getenv("DEVUI_AUTH_TOKEN")),
)
@app.get("/v1/entities", response_model=DiscoveryResponse)
async def discover_entities() -> DiscoveryResponse:
"""List all registered entities."""
@@ -226,7 +430,10 @@ class DevServer:
# Trigger lazy loading if entity not yet loaded
# This will import the module and enrich metadata
entity_obj = await executor.entity_discovery.load_entity(entity_id)
# Pass checkpoint_manager to ensure workflows get checkpoint storage injected
entity_obj = await executor.entity_discovery.load_entity(
entity_id, checkpoint_manager=executor.checkpoint_manager
)
# Get updated entity info (may have been enriched during load)
entity_info = executor.get_entity_info(entity_id) or entity_info
@@ -305,6 +512,7 @@ class DevServer:
executor_list = [getattr(ex, "executor_id", str(ex)) for ex in entity_obj.executors]
# Create copy of entity info and populate workflow-specific fields
# Note: DevUI provides runtime checkpoint storage for ALL workflows via conversations
update_payload: dict[str, Any] = {
"workflow_dump": workflow_dump,
"input_schema": input_schema,
@@ -320,9 +528,13 @@ class DevServer:
except HTTPException:
raise
except ValueError as e:
# ValueError from load_entity indicates entity not found or invalid
error_msg = self._format_error(e, "Entity loading")
raise HTTPException(status_code=404, detail=error_msg) from e
except Exception as e:
logger.error(f"Error getting entity info for {entity_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get entity info: {e!s}") from e
error_msg = self._format_error(e, "Entity info retrieval")
raise HTTPException(status_code=500, detail=error_msg) from e
@app.post("/v1/entities/{entity_id}/reload")
async def reload_entity(entity_id: str) -> dict[str, Any]:
@@ -331,6 +543,7 @@ class DevServer:
This enables hot reload during development - edit entity code, call this endpoint,
and the next execution will use the updated code without server restart.
"""
self._require_developer_mode("entity hot reload")
try:
executor = await self._ensure_executor()
@@ -353,20 +566,150 @@ class DevServer:
logger.error(f"Error reloading entity {entity_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to reload entity: {e!s}") from e
# ============================================================================
# Deployment Endpoints
# ============================================================================
@app.post("/v1/deployments")
async def create_deployment(config: DeploymentConfig) -> StreamingResponse:
"""Deploy entity to Azure Container Apps with streaming events.
Returns SSE stream of deployment progress events.
"""
self._require_developer_mode("deployment")
try:
executor = await self._ensure_executor()
# Validate entity exists and supports deployment
entity_info = executor.get_entity_info(config.entity_id)
if not entity_info:
raise HTTPException(status_code=404, detail=f"Entity {config.entity_id} not found")
if not entity_info.deployment_supported:
reason = entity_info.deployment_reason or "Deployment not supported for this entity"
raise HTTPException(status_code=400, detail=reason)
# Get entity path from metadata
from pathlib import Path
entity_path_str = entity_info.metadata.get("path")
if not entity_path_str:
raise HTTPException(
status_code=400,
detail="Entity path not found in metadata (in-memory entities cannot be deployed)",
)
entity_path = Path(entity_path_str)
# Stream deployment events
async def event_generator() -> AsyncGenerator[str, None]:
async for event in self.deployment_manager.deploy(config, entity_path):
# Format as SSE
import json
yield f"data: {json.dumps(event.model_dump())}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
except HTTPException:
raise
except Exception as e:
error_msg = self._format_error(e, "Deployment creation")
raise HTTPException(status_code=500, detail=error_msg) from e
@app.get("/v1/deployments")
async def list_deployments(entity_id: str | None = None) -> list[Deployment]:
"""List all deployments, optionally filtered by entity."""
self._require_developer_mode("deployment listing")
try:
return await self.deployment_manager.list_deployments(entity_id)
except Exception as e:
error_msg = self._format_error(e, "Deployment listing")
raise HTTPException(status_code=500, detail=error_msg) from e
@app.get("/v1/deployments/{deployment_id}")
async def get_deployment(deployment_id: str) -> Deployment:
"""Get deployment by ID."""
self._require_developer_mode("deployment details")
try:
deployment = await self.deployment_manager.get_deployment(deployment_id)
if not deployment:
raise HTTPException(status_code=404, detail=f"Deployment {deployment_id} not found")
return deployment
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting deployment: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get deployment: {e!s}") from e
@app.delete("/v1/deployments/{deployment_id}")
async def delete_deployment(deployment_id: str) -> dict[str, Any]:
"""Delete deployment from Azure Container Apps."""
self._require_developer_mode("deployment deletion")
try:
await self.deployment_manager.delete_deployment(deployment_id)
return {"success": True, "message": f"Deployment {deployment_id} deleted successfully"}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except Exception as e:
logger.error(f"Error deleting deployment: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete deployment: {e!s}") from e
# Convenience endpoint: deploy specific entity
@app.post("/v1/entities/{entity_id}/deploy")
async def deploy_entity(entity_id: str, config: DeploymentConfig) -> StreamingResponse:
"""Convenience endpoint to deploy entity (shortcuts to /v1/deployments)."""
self._require_developer_mode("deployment")
# Override entity_id from path parameter
config.entity_id = entity_id
return await create_deployment(config)
# ============================================================================
# Response/Conversation Endpoints
# ============================================================================
@app.post("/v1/responses")
async def create_response(request: AgentFrameworkRequest, raw_request: Request) -> Any:
"""OpenAI Responses API endpoint."""
"""OpenAI Responses API endpoint - routes to local or OpenAI executor."""
try:
# Check if frontend requested OpenAI proxy mode
proxy_mode = raw_request.headers.get("X-Proxy-Backend")
if proxy_mode == "openai":
# Route to OpenAI executor
logger.info("🔀 Routing to OpenAI proxy mode")
openai_executor = await self._ensure_openai_executor()
if not openai_executor.is_configured:
error = OpenAIError.create(
"OpenAI proxy mode not configured. Set OPENAI_API_KEY environment variable."
)
return JSONResponse(status_code=503, content=error.to_dict())
# Execute via OpenAI with dedicated streaming method
if request.stream:
return StreamingResponse(
self._stream_openai_execution(openai_executor, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
},
)
return await openai_executor.execute_sync(request)
# Route to local Agent Framework executor (original behavior)
raw_body = await raw_request.body()
logger.info(f"Raw request body: {raw_body.decode()}")
logger.info(f"Parsed request: model={request.model}, extra_body={request.extra_body}")
logger.info(f"Parsed request: metadata={request.metadata}")
# Get entity_id using the new method
# Get entity_id from metadata
entity_id = request.get_entity_id()
logger.info(f"Extracted entity_id: {entity_id}")
if not entity_id:
error = OpenAIError.create(f"Missing entity_id. Request extra_body: {request.extra_body}")
error = OpenAIError.create("Missing entity_id in metadata. Provide metadata.entity_id in request.")
return JSONResponse(status_code=400, content=error.to_dict())
# Get executor and validate entity exists
@@ -392,18 +735,86 @@ class DevServer:
return await executor.execute_sync(request)
except Exception as e:
logger.error(f"Error executing request: {e}")
error = OpenAIError.create(f"Execution failed: {e!s}")
error_msg = self._format_error(e, "Request execution")
error = OpenAIError.create(error_msg)
return JSONResponse(status_code=500, content=error.to_dict())
# ========================================
# OpenAI Conversations API (Standard)
# ========================================
@app.post("/v1/conversations")
async def create_conversation(request_data: dict[str, Any]) -> dict[str, Any]:
"""Create a new conversation - OpenAI standard."""
@app.post("/v1/conversations", response_model=None)
async def create_conversation(raw_request: Request) -> dict[str, Any] | JSONResponse:
"""Create a new conversation - routes to OpenAI or local based on mode."""
try:
# Parse request body
request_data = await raw_request.json()
# Check if frontend requested OpenAI proxy mode
proxy_mode = raw_request.headers.get("X-Proxy-Backend")
if proxy_mode == "openai":
# Create conversation in OpenAI
openai_executor = await self._ensure_openai_executor()
if not openai_executor.is_configured:
error = OpenAIError.create(
"OpenAI proxy mode not configured. Set OPENAI_API_KEY environment variable.",
type="configuration_error",
code="openai_not_configured",
)
return JSONResponse(status_code=503, content=error.to_dict())
# Use OpenAI client to create conversation
from openai import APIStatusError, AsyncOpenAI, AuthenticationError, PermissionDeniedError
client = AsyncOpenAI(
api_key=openai_executor.api_key,
base_url=openai_executor.base_url,
)
try:
metadata = request_data.get("metadata")
logger.debug(f"Creating OpenAI conversation with metadata: {metadata}")
conversation = await client.conversations.create(metadata=metadata)
logger.info(f"Created OpenAI conversation: {conversation.id}")
return conversation.model_dump()
except AuthenticationError as e:
# 401 - Invalid API key or authentication issue
logger.error(f"OpenAI authentication error creating conversation: {e}")
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
error = OpenAIError.create(
message=error_data.get("message", str(e)),
type=error_data.get("type", "authentication_error"),
code=error_data.get("code", "invalid_api_key"),
)
return JSONResponse(status_code=401, content=error.to_dict())
except PermissionDeniedError as e:
# 403 - Permission denied
logger.error(f"OpenAI permission denied creating conversation: {e}")
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
error = OpenAIError.create(
message=error_data.get("message", str(e)),
type=error_data.get("type", "permission_denied"),
code=error_data.get("code", "insufficient_permissions"),
)
return JSONResponse(status_code=403, content=error.to_dict())
except APIStatusError as e:
# Other OpenAI API errors (rate limit, etc.)
logger.error(f"OpenAI API error creating conversation: {e}")
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
error = OpenAIError.create(
message=error_data.get("message", str(e)),
type=error_data.get("type", "api_error"),
code=error_data.get("code", "unknown_error"),
)
return JSONResponse(
status_code=e.status_code if hasattr(e, "status_code") else 500, content=error.to_dict()
)
# Local mode - use DevUI conversation store
metadata = request_data.get("metadata")
executor = await self._ensure_executor()
conversation = executor.conversation_store.create_conversation(metadata=metadata)
@@ -411,22 +822,39 @@ class DevServer:
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating conversation: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create conversation: {e!s}") from e
logger.error(f"Error creating conversation: {e}", exc_info=True)
error = OpenAIError.create(f"Failed to create conversation: {e!s}")
return JSONResponse(status_code=500, content=error.to_dict())
@app.get("/v1/conversations")
async def list_conversations(agent_id: str | None = None) -> dict[str, Any]:
"""List conversations, optionally filtered by agent_id."""
async def list_conversations(
agent_id: str | None = None,
entity_id: str | None = None,
type: str | None = None,
) -> dict[str, Any]:
"""List conversations, optionally filtered by agent_id, entity_id, and/or type.
Query Parameters:
- agent_id: Filter by agent_id (for agent conversations)
- entity_id: Filter by entity_id (for workflow sessions or other entities)
- type: Filter by conversation type (e.g., "workflow_session")
Multiple filters can be combined (AND logic).
"""
try:
executor = await self._ensure_executor()
# Build filter criteria
filters = {}
if agent_id:
# Filter by agent_id metadata
conversations = executor.conversation_store.list_conversations_by_metadata({"agent_id": agent_id})
else:
# Return all conversations (for InMemoryStore, list all)
# Note: This assumes list_conversations_by_metadata({}) returns all
conversations = executor.conversation_store.list_conversations_by_metadata({})
filters["agent_id"] = agent_id
if entity_id:
filters["entity_id"] = entity_id
if type:
filters["type"] = type
# Apply filters
conversations = executor.conversation_store.list_conversations_by_metadata(filters)
return {
"object": "list",
@@ -511,9 +939,20 @@ class DevServer:
items, has_more = await executor.conversation_store.list_items(
conversation_id, limit=limit, after=after, order=order
)
# Handle both Pydantic models and dicts (some stores return raw dicts)
serialized_items = []
for item in items:
if hasattr(item, "model_dump"):
serialized_items.append(item.model_dump())
elif isinstance(item, dict):
serialized_items.append(item)
else:
logger.warning(f"Unexpected item type: {type(item)}, converting to dict")
serialized_items.append(dict(item))
return {
"object": "list",
"data": [item.model_dump() for item in items],
"data": serialized_items,
"has_more": has_more,
}
except ValueError as e:
@@ -532,13 +971,51 @@ class DevServer:
item = executor.conversation_store.get_item(conversation_id, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return item.model_dump()
result: dict[str, Any] = item.model_dump()
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting item {item_id} from conversation {conversation_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get item: {e!s}") from e
@app.delete("/v1/conversations/{conversation_id}/items/{item_id}")
async def delete_conversation_item(conversation_id: str, item_id: str) -> dict[str, Any]:
"""Delete conversation item - supports checkpoint deletion."""
try:
executor = await self._ensure_executor()
# Check if this is a checkpoint item
if item_id.startswith("checkpoint_"):
# Extract checkpoint_id from item_id (format: "checkpoint_{checkpoint_id}")
checkpoint_id = item_id[len("checkpoint_") :]
storage = executor.checkpoint_manager.get_checkpoint_storage(conversation_id)
deleted = await storage.delete_checkpoint(checkpoint_id)
if not deleted:
raise HTTPException(status_code=404, detail="Checkpoint not found")
return {
"id": item_id,
"object": "item.deleted",
"deleted": True,
}
# For other items, delegate to conversation store (if it supports deletion)
raise HTTPException(status_code=501, detail="Deletion of non-checkpoint items not implemented")
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except Exception as e:
logger.error(f"Error deleting item {item_id} from conversation {conversation_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete item: {e!s}") from e
# ============================================================================
# Checkpoint Management - Now handled through conversation items API
# Checkpoints are exposed as conversation items with type="checkpoint"
# ============================================================================
async def _stream_execution(
self, executor: AgentFrameworkExecutor, request: AgentFrameworkRequest
) -> AsyncGenerator[str, None]:
@@ -587,6 +1064,63 @@ class DevServer:
error_event = {"id": "error", "object": "error", "error": {"message": str(e), "type": "execution_error"}}
yield f"data: {json.dumps(error_event)}\n\n"
async def _stream_openai_execution(
self, executor: OpenAIExecutor, request: AgentFrameworkRequest
) -> AsyncGenerator[str, None]:
"""Stream execution through OpenAI executor.
OpenAI events are already in final format - no conversion or aggregation needed.
Just serialize and stream them as SSE.
Args:
executor: OpenAI executor instance
request: Request to execute
Yields:
SSE-formatted event strings
"""
try:
# Stream events from OpenAI - they're already ResponseStreamEvent objects
async for event in executor.execute_streaming(request):
# Handle error dicts from executor
if isinstance(event, dict):
payload = json.dumps(event)
yield f"data: {payload}\n\n"
continue
# OpenAI SDK events have model_dump_json() - use it for single-line JSON
if hasattr(event, "model_dump_json"):
payload = event.model_dump_json() # type: ignore[attr-defined]
yield f"data: {payload}\n\n"
else:
# Fallback (shouldn't happen with OpenAI SDK)
logger.warning(f"Unexpected event type from OpenAI: {type(event)}")
payload = json.dumps(str(event))
yield f"data: {payload}\n\n"
# OpenAI already sends response.completed event - no aggregation needed!
# Just send [DONE] marker
yield "data: [DONE]\n\n"
except Exception as e:
logger.error(f"Error in OpenAI streaming execution: {e}", exc_info=True)
# Emit proper response.failed event
import os
error_event = {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": str(e),
"type": "internal_error",
"code": "streaming_error",
},
},
}
yield f"data: {json.dumps(error_event)}\n\n"
def _mount_ui(self, app: FastAPI) -> None:
"""Mount the UI as static files."""
from pathlib import Path
@@ -324,6 +324,71 @@ def generate_schema_from_dataclass(cls: type[Any]) -> dict[str, Any]:
return schema
def extract_response_type_from_executor(executor: Any, request_type: type) -> type | None:
"""Extract the expected response type from an executor's response handler.
Looks for methods decorated with @response_handler that have signature:
async def handler(self, original_request: RequestType, response: ResponseType, ctx)
Args:
executor: Executor object that should have a handler for the request type
request_type: The request message type
Returns:
The response type class, or None if not found
"""
try:
from typing import get_type_hints
# Introspect handler methods for @response_handler pattern
for attr_name in dir(executor):
if attr_name.startswith("_"):
continue
attr = getattr(executor, attr_name, None)
if not callable(attr):
continue
# Get type hints for this method
try:
type_hints = get_type_hints(attr)
# Check for @response_handler pattern:
# async def handler(self, original_request: RequestType, response: ResponseType, ctx)
type_hint_params = {k: v for k, v in type_hints.items() if k not in ("self", "return")}
# Look for at least 2 parameters: original_request, response (ctx is optional)
if len(type_hint_params) >= 2:
param_items = list(type_hint_params.items())
# First param should be original_request matching request_type
_, first_param_type = param_items[0]
_, second_param_type = param_items[1] if len(param_items) > 1 else (None, None)
# Check if first param matches request_type
first_matches_request = first_param_type == request_type or (
hasattr(first_param_type, "__name__")
and hasattr(request_type, "__name__")
and first_param_type.__name__ == request_type.__name__
)
# Verify we have a matching request type and valid response type (must be a type class)
if first_matches_request and second_param_type is not None and isinstance(second_param_type, type):
response_type_class: type = second_param_type
logger.debug(
f"Found response type {response_type_class} for request {request_type} "
f"via @response_handler"
)
return response_type_class
except Exception as e:
logger.debug(f"Failed to get type hints for {attr_name}: {e}")
continue
except Exception as e:
logger.debug(f"Failed to extract response type from executor: {e}")
return None
def generate_input_schema(input_type: type) -> dict[str, Any]:
"""Generate JSON schema for workflow input type.
@@ -27,14 +27,18 @@ from openai.types.responses import (
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
from openai.types.shared import Metadata, ResponsesModel
from ._discovery_models import DiscoveryResponse, EntityInfo
from ._discovery_models import Deployment, DeploymentConfig, DeploymentEvent, DiscoveryResponse, EntityInfo
from ._openai_custom import (
AgentFrameworkRequest,
CustomResponseOutputItemAddedEvent,
CustomResponseOutputItemDoneEvent,
ExecutorActionItem,
MetaResponse,
OpenAIError,
ResponseFunctionResultComplete,
ResponseOutputData,
ResponseOutputFile,
ResponseOutputImage,
ResponseTraceEvent,
ResponseTraceEventComplete,
ResponseWorkflowEventComplete,
@@ -51,10 +55,14 @@ __all__ = [
"ConversationItem",
"CustomResponseOutputItemAddedEvent",
"CustomResponseOutputItemDoneEvent",
"Deployment",
"DeploymentConfig",
"DeploymentEvent",
"DiscoveryResponse",
"EntityInfo",
"ExecutorActionItem",
"InputTokensDetails",
"MetaResponse",
"Metadata",
"OpenAIError",
"OpenAIResponse",
@@ -67,6 +75,9 @@ __all__ = [
"ResponseFunctionToolCall",
"ResponseFunctionToolCallOutputItem",
"ResponseInputParam",
"ResponseOutputData",
"ResponseOutputFile",
"ResponseOutputImage",
"ResponseOutputItemAddedEvent",
"ResponseOutputItemDoneEvent",
"ResponseOutputMessage",
@@ -4,9 +4,10 @@
from __future__ import annotations
import re
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
class EnvVarRequirement(BaseModel):
@@ -36,6 +37,10 @@ class EntityInfo(BaseModel):
# Environment variable requirements
required_env_vars: list[EnvVarRequirement] | None = None
# Deployment support
deployment_supported: bool = False # Whether entity can be deployed
deployment_reason: str | None = None # Explanation of why/why not entity can be deployed
# Agent-specific fields (optional, populated when available)
instructions: str | None = None
model_id: str | None = None
@@ -55,3 +60,144 @@ class DiscoveryResponse(BaseModel):
"""Response model for entity discovery."""
entities: list[EntityInfo] = Field(default_factory=list)
# ============================================================================
# Deployment Models
# ============================================================================
class DeploymentConfig(BaseModel):
"""Configuration for deploying an entity."""
entity_id: str = Field(description="Entity ID to deploy")
resource_group: str = Field(description="Azure resource group name")
app_name: str = Field(description="Azure Container App name")
region: str = Field(default="eastus", description="Azure region")
ui_mode: str = Field(default="user", description="UI mode (user or developer)")
ui_enabled: bool = Field(default=True, description="Whether to enable web interface")
stream: bool = Field(default=True, description="Stream deployment events")
@field_validator("app_name")
@classmethod
def validate_app_name(cls, v: str) -> str:
"""Validate Azure Container App name format.
Azure Container App names must:
- Be 3-32 characters long
- Contain only lowercase letters, numbers, and hyphens
- Start with a lowercase letter
- End with a lowercase letter or number
- Not contain consecutive hyphens
"""
if not v:
raise ValueError("app_name cannot be empty")
if len(v) < 3 or len(v) > 32:
raise ValueError("app_name must be between 3 and 32 characters")
if not re.match(r"^[a-z][a-z0-9-]*[a-z0-9]$", v):
raise ValueError(
"app_name must start with a lowercase letter, "
"end with a letter or number, and contain only lowercase letters, numbers, and hyphens"
)
if "--" in v:
raise ValueError("app_name cannot contain consecutive hyphens")
return v
@field_validator("resource_group")
@classmethod
def validate_resource_group(cls, v: str) -> str:
"""Validate Azure resource group name format.
Azure resource group names must:
- Be 1-90 characters long
- Contain only alphanumeric, underscore, parentheses, hyphen, period (except at end)
- Not end with a period
"""
if not v:
raise ValueError("resource_group cannot be empty")
if len(v) > 90:
raise ValueError("resource_group must be 90 characters or less")
if not re.match(r"^[a-zA-Z0-9._()-]+$", v):
raise ValueError(
"resource_group can only contain alphanumeric characters, "
"underscores, hyphens, periods, and parentheses"
)
if v.endswith("."):
raise ValueError("resource_group cannot end with a period")
return v
@field_validator("region")
@classmethod
def validate_region(cls, v: str) -> str:
"""Validate Azure region format.
Validates that the region string is a reasonable format.
Does not validate against the full list of Azure regions (which changes).
"""
if not v:
raise ValueError("region cannot be empty")
if len(v) > 50:
raise ValueError("region name too long")
# Azure regions are typically lowercase with no spaces (e.g., eastus, westeurope)
if not re.match(r"^[a-z0-9]+$", v):
raise ValueError("region must contain only lowercase letters and numbers (e.g., eastus, westeurope)")
return v
@field_validator("entity_id")
@classmethod
def validate_entity_id(cls, v: str) -> str:
"""Validate entity_id format to prevent injection attacks."""
if not v:
raise ValueError("entity_id cannot be empty")
if len(v) > 256:
raise ValueError("entity_id too long")
# Allow alphanumeric, hyphens, underscores, and periods
if not re.match(r"^[a-zA-Z0-9._-]+$", v):
raise ValueError("entity_id contains invalid characters")
return v
@field_validator("ui_mode")
@classmethod
def validate_ui_mode(cls, v: str) -> str:
"""Validate ui_mode is one of the allowed values."""
if v not in ("user", "developer"):
raise ValueError("ui_mode must be 'user' or 'developer'")
return v
class DeploymentEvent(BaseModel):
"""Real-time deployment event (SSE)."""
type: str = Field(description="Event type (e.g., deploy.validating, deploy.building)")
message: str = Field(description="Human-readable message")
url: str | None = Field(default=None, description="Deployment URL (on completion)")
auth_token: str | None = Field(default=None, description="Auth token (on completion, shown once)")
class Deployment(BaseModel):
"""Deployment record."""
id: str = Field(description="Deployment ID (UUID)")
entity_id: str = Field(description="Entity ID that was deployed")
resource_group: str = Field(description="Azure resource group")
app_name: str = Field(description="Azure Container App name")
region: str = Field(description="Azure region")
url: str = Field(description="Deployment URL")
status: str = Field(description="Deployment status (deploying, deployed, failed)")
created_at: str = Field(description="ISO 8601 timestamp")
error: str | None = Field(default=None, description="Error message if failed")
@@ -80,9 +80,16 @@ class CustomResponseOutputItemDoneEvent(BaseModel):
class ResponseWorkflowEventComplete(BaseModel):
"""Complete workflow event data."""
"""Complete workflow event data.
type: Literal["response.workflow_event.complete"] = "response.workflow_event.complete"
DevUI extension for workflow execution events (debugging/observability).
Uses past-tense 'completed' to follow OpenAI's event naming pattern.
Workflow events are shown in the debug panel for monitoring execution flow,
not in main chat. Use response.output_item.added for user-facing content.
"""
type: Literal["response.workflow_event.completed"] = "response.workflow_event.completed"
data: dict[str, Any] # Complete event data, not delta
executor_id: str | None = None
item_id: str
@@ -91,9 +98,17 @@ class ResponseWorkflowEventComplete(BaseModel):
class ResponseTraceEventComplete(BaseModel):
"""Complete trace event data."""
"""Complete trace event data.
type: Literal["response.trace.complete"] = "response.trace.complete"
DevUI extension for non-displayable debugging/metadata events.
Uses past-tense 'completed' to follow OpenAI's event naming pattern
(e.g., response.completed, response.output_item.added).
Trace events are shown in the Traces debug panel, not in main chat.
Use response.output_item.added for user-facing content.
"""
type: Literal["response.trace.completed"] = "response.trace.completed"
data: dict[str, Any] # Complete trace data, not delta
span_id: str | None = None
item_id: str
@@ -124,6 +139,139 @@ class ResponseFunctionResultComplete(BaseModel):
timestamp: str | None = None # Optional timestamp for UI display
class ResponseRequestInfoEvent(BaseModel):
"""DevUI extension: Workflow requests human input.
This is a DevUI extension because:
- OpenAI Responses API doesn't have a concept of workflow human-in-the-loop pausing
- Agent Framework workflows can pause via RequestInfoExecutor to collect external information
- Clients need to render forms and submit responses to continue workflow execution
When a workflow emits this event, it enters IDLE_WITH_PENDING_REQUESTS state.
Client should render a form based on request_schema and submit responses via
a new request with workflow_hil_response content type.
"""
type: Literal["response.request_info.requested"] = "response.request_info.requested"
request_id: str
"""Unique identifier for correlating this request with the response."""
source_executor_id: str
"""ID of the executor that is waiting for this response."""
request_type: str
"""Fully qualified type name of the request (e.g., 'module.path:ClassName')."""
request_data: dict[str, Any]
"""Current data from the RequestInfoMessage (may contain defaults/context)."""
request_schema: dict[str, Any]
"""JSON schema describing the request data structure (what the workflow is asking about)."""
response_schema: dict[str, Any] | None = None
"""JSON schema describing the expected response structure for form rendering (what user should provide)."""
item_id: str
"""OpenAI item ID for correlation."""
output_index: int = 0
"""Output index for OpenAI compatibility."""
sequence_number: int
"""Sequence number for ordering events."""
timestamp: str
"""ISO timestamp when the request was made."""
# DevUI Output Content Types - for agent-generated media/data
# These extend ResponseOutputItem to support rich content outputs that OpenAI's API doesn't natively support
class ResponseOutputImage(BaseModel):
"""DevUI extension: Agent-generated image output.
This is a DevUI extension because:
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
- ImageGenerationCall exists but is for tool calls (generating images), not returning existing images
- Agent Framework agents can return images via DataContent/UriContent that need proper display
This type allows images to be displayed inline in chat rather than hidden in trace logs.
"""
id: str
"""The unique ID of the image output."""
image_url: str
"""The URL or data URI of the image (e.g., data:image/png;base64,...)"""
type: Literal["output_image"] = "output_image"
"""The type of the output. Always `output_image`."""
alt_text: str | None = None
"""Optional alt text for accessibility."""
mime_type: str = "image/png"
"""The MIME type of the image (e.g., image/png, image/jpeg)."""
class ResponseOutputFile(BaseModel):
"""DevUI extension: Agent-generated file output.
This is a DevUI extension because:
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
- Agent Framework agents can return files via DataContent/UriContent that need proper display
- Supports PDFs, audio files, and other media types
This type allows files to be displayed inline in chat with appropriate renderers.
"""
id: str
"""The unique ID of the file output."""
filename: str
"""The filename (used to determine rendering and download)."""
type: Literal["output_file"] = "output_file"
"""The type of the output. Always `output_file`."""
file_url: str | None = None
"""Optional URL to the file."""
file_data: str | None = None
"""Optional base64-encoded file data."""
mime_type: str = "application/octet-stream"
"""The MIME type of the file (e.g., application/pdf, audio/mp3)."""
class ResponseOutputData(BaseModel):
"""DevUI extension: Agent-generated generic data output.
This is a DevUI extension because:
- OpenAI Responses API only supports text output in ResponseOutputMessage.content
- Agent Framework agents can return arbitrary structured data that needs display
- Useful for debugging and displaying non-text content
This type allows generic data to be displayed inline in chat.
"""
id: str
"""The unique ID of the data output."""
data: str
"""The data payload (string representation)."""
type: Literal["output_data"] = "output_data"
"""The type of the output. Always `output_data`."""
mime_type: str
"""The MIME type of the data."""
description: str | None = None
"""Optional description of the data."""
# Agent Framework extension fields
class AgentFrameworkExtraBody(BaseModel):
"""Agent Framework specific routing fields for OpenAI requests."""
@@ -144,7 +292,7 @@ class AgentFrameworkRequest(BaseModel):
"""
# All OpenAI fields from ResponseCreateParams
model: str # Used as entity_id in DevUI!
model: str | None = None
input: str | list[Any] | dict[str, Any] # ResponseInputParam + dict for workflow structured input
stream: bool | None = False
@@ -156,20 +304,25 @@ class AgentFrameworkRequest(BaseModel):
metadata: dict[str, Any] | None = None
temperature: float | None = None
max_output_tokens: int | None = None
top_p: float | None = None
tools: list[dict[str, Any]] | None = None
# Reasoning parameters (for o-series models)
reasoning: dict[str, Any] | None = None # {"effort": "low" | "medium" | "high" | "minimal"}
# Optional extra_body for advanced use cases
extra_body: dict[str, Any] | None = None
model_config = ConfigDict(extra="allow")
def get_entity_id(self) -> str:
"""Get entity_id from model field.
def get_entity_id(self) -> str | None:
"""Get entity_id from metadata.entity_id.
In DevUI, model IS the entity_id (agent/workflow name).
Simple and clean!
In DevUI, entity_id is specified in metadata for routing.
"""
return self.model
if self.metadata:
return self.metadata.get("entity_id")
return None
def get_conversation_id(self) -> str | None:
"""Extract conversation_id from conversation parameter.
@@ -218,11 +371,40 @@ class OpenAIError(BaseModel):
return self.model_dump_json()
class MetaResponse(BaseModel):
"""Server metadata response for /meta endpoint.
Provides information about the DevUI server configuration and capabilities.
"""
ui_mode: Literal["developer", "user"] = "developer"
"""UI interface mode - 'developer' shows debug tools, 'user' shows simplified interface."""
version: str
"""DevUI version string."""
framework: str = "agent_framework"
"""Backend framework identifier."""
runtime: Literal["python", "dotnet"] = "python"
"""Backend runtime/language - 'python' or 'dotnet' for deployment guides and feature availability."""
capabilities: dict[str, bool] = {}
"""Server capabilities (e.g., tracing, openai_proxy)."""
auth_required: bool = False
"""Whether the server requires Bearer token authentication."""
# Export all custom types
__all__ = [
"AgentFrameworkRequest",
"MetaResponse",
"OpenAIError",
"ResponseFunctionResultComplete",
"ResponseOutputData",
"ResponseOutputFile",
"ResponseOutputImage",
"ResponseTraceEvent",
"ResponseTraceEventComplete",
"ResponseWorkflowEventComplete",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -15,7 +15,9 @@
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.12",
"@xyflow/react": "^12.8.4",
+273 -82
View File
@@ -3,33 +3,49 @@
* Features: Entity selection, layout management, debug coordination
*/
import { useEffect, useCallback } from "react";
import { useEffect, useCallback, useState } from "react";
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
import { GalleryView } from "@/components/features/gallery";
import { AgentView } from "@/components/features/agent";
import { WorkflowView } from "@/components/features/workflow";
import { Toast } from "@/components/ui/toast";
import { Toast, ToastContainer } from "@/components/ui/toast";
import { apiClient } from "@/services/api";
import { PanelRightOpen, ChevronDown, ServerOff, Rocket } from "lucide-react";
import { PanelRightOpen, ChevronLeft, ChevronDown, ServerOff, Rocket, Lock } from "lucide-react";
import type {
AgentInfo,
WorkflowInfo,
ExtendedResponseStreamEvent,
} from "@/types";
import { Button } from "./components/ui/button";
import { Input } from "./components/ui/input";
import { useDevUIStore } from "@/stores";
export default function App() {
// Local state for auth handling
const [authRequired, setAuthRequired] = useState(false);
const [authToken, setAuthToken] = useState("");
const [isTestingToken, setIsTestingToken] = useState(false);
const [authError, setAuthError] = useState("");
// Entity state from Zustand
const agents = useDevUIStore((state) => state.agents);
const workflows = useDevUIStore((state) => state.workflows);
const entities = useDevUIStore((state) => state.entities);
const selectedAgent = useDevUIStore((state) => state.selectedAgent);
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
const isLoadingEntities = useDevUIStore((state) => state.isLoadingEntities);
const entityError = useDevUIStore((state) => state.entityError);
// OpenAI proxy mode
const oaiMode = useDevUIStore((state) => state.oaiMode);
// UI mode
const uiMode = useDevUIStore((state) => state.uiMode);
// Entity actions
const setAgents = useDevUIStore((state) => state.setAgents);
const setWorkflows = useDevUIStore((state) => state.setWorkflows);
const setEntities = useDevUIStore((state) => state.setEntities);
const selectEntity = useDevUIStore((state) => state.selectEntity);
const updateAgent = useDevUIStore((state) => state.updateAgent);
const updateWorkflow = useDevUIStore((state) => state.updateWorkflow);
@@ -38,12 +54,14 @@ export default function App() {
// UI state from Zustand
const showDebugPanel = useDevUIStore((state) => state.showDebugPanel);
const debugPanelMinimized = useDevUIStore((state) => state.debugPanelMinimized);
const debugPanelWidth = useDevUIStore((state) => state.debugPanelWidth);
const debugEvents = useDevUIStore((state) => state.debugEvents);
const isResizing = useDevUIStore((state) => state.isResizing);
// UI actions
const setShowDebugPanel = useDevUIStore((state) => state.setShowDebugPanel);
const setDebugPanelMinimized = useDevUIStore((state) => state.setDebugPanelMinimized);
const setDebugPanelWidth = useDevUIStore((state) => state.setDebugPanelWidth);
const addDebugEvent = useDevUIStore((state) => state.addDebugEvent);
const clearDebugEvents = useDevUIStore((state) => state.clearDebugEvents);
@@ -61,13 +79,40 @@ export default function App() {
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
// Toast state and actions
const toasts = useDevUIStore((state) => state.toasts);
const removeToast = useDevUIStore((state) => state.removeToast);
// Initialize app - load agents and workflows
useEffect(() => {
const loadData = async () => {
try {
// Single API call instead of two parallel calls to same endpoint
const { agents: agentList, workflows: workflowList } = await apiClient.getEntities();
// Fetch server metadata first (ui_mode, capabilities, auth status)
const meta = await apiClient.getMeta();
// Check if auth is required
if (meta.auth_required) {
setAuthRequired(true);
// If we don't have a token, stop here and show auth UI
if (!apiClient.getAuthToken()) {
setEntityError("UNAUTHORIZED");
setIsLoadingEntities(false);
return;
}
}
useDevUIStore.getState().setServerMeta({
uiMode: meta.ui_mode,
runtime: meta.runtime,
capabilities: meta.capabilities,
authRequired: meta.auth_required,
});
// Single API call instead of two parallel calls to same endpoint
const { entities: allEntities, agents: agentList, workflows: workflowList } = await apiClient.getEntities();
setEntities(allEntities);
setAgents(agentList);
setWorkflows(workflowList);
@@ -79,9 +124,7 @@ export default function App() {
// Try to find entity from URL parameter first
if (entityId) {
selectedEntity =
agentList.find((a) => a.id === entityId) ||
workflowList.find((w) => w.id === entityId);
selectedEntity = allEntities.find((e) => e.id === entityId);
// If entity not found but was requested, show notification
if (!selectedEntity) {
@@ -91,12 +134,9 @@ export default function App() {
// Fallback to first available entity if URL entity not found
if (!selectedEntity) {
selectedEntity =
agentList.length > 0
? agentList[0]
: workflowList.length > 0
? workflowList[0]
: undefined;
// Use the first entity from the backend's original order
// This respects the backend's intended display order
selectedEntity = allEntities.length > 0 ? allEntities[0] : undefined;
// Update URL to match actual selected entity (or clear if none)
if (selectedEntity) {
@@ -140,9 +180,14 @@ export default function App() {
setIsLoadingEntities(false);
} catch (error) {
console.error("Failed to load agents/workflows:", error);
setEntityError(
error instanceof Error ? error.message : "Failed to load data"
);
const errorMessage = error instanceof Error ? error.message : "Failed to load data";
// Check if this is an auth error
if (errorMessage === "UNAUTHORIZED") {
setAuthRequired(true);
}
setEntityError(errorMessage);
setIsLoadingEntities(false);
}
};
@@ -150,6 +195,47 @@ export default function App() {
loadData();
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast]);
// Handle auth token submission
const handleAuthTokenSubmit = useCallback(async () => {
if (!authToken.trim()) return;
setIsTestingToken(true);
setAuthError("");
try {
// Set token in API client (stores in localStorage)
apiClient.setAuthToken(authToken.trim());
// Test the token with an actual PROTECTED endpoint (not /meta which is public)
await apiClient.getEntities();
// If successful, reload to initialize with new token
window.location.reload();
} catch (error) {
// Token is invalid - clear it and show error
apiClient.clearAuthToken();
setIsTestingToken(false);
const errorMsg = error instanceof Error ? error.message : "Unknown error";
if (errorMsg === "UNAUTHORIZED") {
setAuthError("Invalid token. Please check and try again.");
} else {
setAuthError(`Failed to connect: ${errorMsg}`);
}
}
}, [authToken]);
// Auto-switch from workflow to agent when OpenAI proxy mode is enabled
useEffect(() => {
if (oaiMode.enabled && selectedAgent?.type === "workflow") {
// Workflows don't work with OpenAI proxy - switch to first available agent
const firstAgent = agents[0];
if (firstAgent) {
selectEntity(firstAgent);
}
}
}, [oaiMode.enabled, selectedAgent, agents, selectEntity]);
// Handle resize drag
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
@@ -242,12 +328,14 @@ export default function App() {
// Show error state if loading failed
if (entityError) {
const currentBackendUrl = apiClient.getBaseUrl();
const isAuthError = entityError === "UNAUTHORIZED" || authRequired;
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader
agents={[]}
workflows={[]}
entities={[]}
selectedItem={undefined}
onSelect={() => {}}
isLoading={false}
@@ -260,63 +348,124 @@ export default function App() {
{/* Icon */}
<div className="flex justify-center">
<div className="rounded-full bg-muted p-4 animate-pulse">
<ServerOff className="h-12 w-12 text-muted-foreground" />
{isAuthError ? (
<Lock className="h-12 w-12 text-muted-foreground" />
) : (
<ServerOff className="h-12 w-12 text-muted-foreground" />
)}
</div>
</div>
{/* Heading */}
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">
Can't Connect to Backend
{isAuthError ? "Authentication Required" : "Can't Connect to Backend"}
</h2>
<p className="text-muted-foreground text-base">
No worries! Just start the DevUI backend server and you'll be
good to go.
{isAuthError
? "This backend requires a bearer token to access."
: "No worries! Just start the DevUI backend server and you'll be good to go."}
</p>
</div>
{/* Command Instructions */}
<div className="space-y-3">
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-foreground">
Start the backend:
</p>
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
devui ./agents --port 8080
</code>
<p className="text-xs text-muted-foreground">
Or launch programmatically with{" "}
<code className="text-xs">serve(entities=[agent])</code>
</p>
{/* Auth Input or Command Instructions */}
{isAuthError ? (
<div className="space-y-4">
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-foreground">
Enter Authentication Token
</p>
<Input
type="password"
placeholder="Paste token from server logs"
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isTestingToken) {
handleAuthTokenSubmit();
}
}}
disabled={isTestingToken}
className="font-mono text-sm"
/>
<Button
onClick={handleAuthTokenSubmit}
disabled={!authToken.trim() || isTestingToken}
className="w-full"
>
{isTestingToken ? "Verifying..." : "Connect"}
</Button>
{/* Error message */}
{authError && (
<p className="text-sm text-red-600 dark:text-red-400 text-center">
{authError}
</p>
)}
</div>
<details className="text-left group">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2 justify-center">
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
Where do I find the token?
</summary>
<div className="mt-3 text-left bg-muted/30 rounded-lg p-3 space-y-2">
<p className="text-xs text-muted-foreground">
Look for this in your DevUI server startup logs:
</p>
<code className="block bg-background px-2 py-1 rounded text-xs font-mono text-foreground">
🔑 DEV TOKEN (localhost only, shown once):
<br />
&nbsp;&nbsp; abc123xyz...
</code>
</div>
</details>
</div>
) : (
<>
<div className="space-y-3">
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-foreground">
Start the backend:
</p>
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
devui ./agents --port 8080
</code>
<p className="text-xs text-muted-foreground">
Or launch programmatically with{" "}
<code className="text-xs">serve(entities=[agent])</code>
</p>
</div>
<p className="text-xs text-muted-foreground">
Default:{" "}
<span className="font-mono">{currentBackendUrl}</span>
</p>
</div>
<p className="text-xs text-muted-foreground">
Default:{" "}
<span className="font-mono">{currentBackendUrl}</span>
</p>
</div>
{/* Error Details (Collapsible) */}
{entityError && (
<details className="text-left group">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
Error details
</summary>
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
{entityError}
</p>
</details>
{/* Error Details (Collapsible) */}
{entityError && (
<details className="text-left group">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
Error details
</summary>
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
{entityError}
</p>
</details>
)}
{/* Retry Button */}
<Button
onClick={() => window.location.reload()}
variant="default"
className="mt-2"
>
Retry Connection
</Button>
</>
)}
{/* Retry Button */}
<Button
onClick={() => window.location.reload()}
variant="default"
className="mt-2"
>
Retry Connection
</Button>
</div>
</div>
@@ -331,6 +480,7 @@ export default function App() {
<AppHeader
agents={agents}
workflows={workflows}
entities={entities}
selectedItem={selectedAgent}
onSelect={handleEntitySelect}
onBrowseGallery={() => setShowGallery(true)}
@@ -377,7 +527,7 @@ export default function App() {
)}
</div>
{showDebugPanel ? (
{uiMode === "developer" && showDebugPanel ? (
<>
{/* Resize Handle */}
<div
@@ -400,31 +550,68 @@ export default function App() {
{/* Right Panel - Debug */}
<div
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
style={{ width: `${debugPanelWidth}px` }}
style={{ width: debugPanelMinimized ? '2.5rem' : `${debugPanelWidth}px` }}
>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
onClose={() => setShowDebugPanel(false)}
/>
{/* Deploy Footer - Pinned to bottom */}
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
<Button
onClick={() => setShowDeployModal(true)}
className="w-full"
variant="outline"
size="sm"
{debugPanelMinimized ? (
/* Minimized Debug Panel - Vertical Bar (fully clickable) */
<div
className="h-full w-10 bg-background border-l flex flex-col items-center py-2 cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => setDebugPanelMinimized(false)}
title="Expand debug panel"
>
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate text-xs">
Deployment Guide for {selectedAgent?.name || "Agent"}
</span>
</Button>
</div>
{/* Expand button at top (visual affordance) */}
<div className="h-8 w-8 flex items-center justify-center">
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
</div>
{/* Text and count centered in middle */}
<div className="flex-1 flex flex-col items-center justify-center gap-2 pointer-events-none">
<div
className="text-xs text-muted-foreground select-none"
style={{
writingMode: 'vertical-rl',
transform: 'rotate(180deg)'
}}
>
Debug Panel
</div>
{debugEvents.length > 0 && (
<div className="bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center"
style={{ fontSize: '10px' }}>
{debugEvents.length}
</div>
)}
</div>
</div>
) : (
<>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
onMinimize={() => setDebugPanelMinimized(true)}
/>
{/* Deploy Footer - Pinned to bottom */}
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
<Button
onClick={() => setShowDeployModal(true)}
className="w-full"
variant="outline"
size="sm"
>
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate text-xs">
{azureDeploymentEnabled && selectedAgent?.deployment_supported
? "Deploy to Azure"
: "Deployment Guide"}
</span>
</Button>
</div>
</>
)}
</div>
</>
) : (
) : uiMode === "developer" ? (
/* Button to reopen when closed */
<div className="flex-shrink-0">
<Button
@@ -437,7 +624,7 @@ export default function App() {
<PanelRightOpen className="h-4 w-4" />
</Button>
</div>
)}
) : null}
</>
)}
</div>
@@ -450,6 +637,7 @@ export default function App() {
open={showDeployModal}
onClose={() => setShowDeployModal(false)}
agentName={selectedAgent?.name}
entity={selectedAgent}
/>
{/* Toast Notification */}
@@ -460,6 +648,9 @@ export default function App() {
onClose={() => setShowEntityNotFoundToast(false)}
/>
)}
{/* Toast Container for reload and other notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
);
}
@@ -36,6 +36,7 @@ import {
X,
Copy,
CheckCheck,
RefreshCw,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
@@ -118,7 +119,7 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
>
<div className="relative group">
<div
className={`rounded px-3 py-2 text-sm break-all ${
className={`rounded px-3 py-2 text-sm ${
isUser
? "bg-primary text-primary-foreground"
: isError
@@ -161,7 +162,12 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
<span>{new Date().toLocaleTimeString()}</span>
<span>
{item.created_at
? new Date(item.created_at * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString() // Fallback for legacy items without timestamp
}
</span>
{!isUser && item.usage && (
<>
<span></span>
@@ -207,8 +213,10 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
const inputValue = useDevUIStore((state) => state.inputValue);
const attachments = useDevUIStore((state) => state.attachments);
const uiMode = useDevUIStore((state) => state.uiMode);
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
const oaiMode = useDevUIStore((state) => state.oaiMode);
// Get conversation actions from Zustand (only the ones we actually use)
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
@@ -227,6 +235,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [dragCounter, setDragCounter] = useState(0);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [conversationError, setConversationError] = useState<{
message: string;
code?: string;
type?: string;
} | null>(null);
const [isReloading, setIsReloading] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -604,10 +618,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setAvailableConversations([newConversation]);
setChatItems([]);
setIsStreaming(false);
} catch {
setConversationError(null); // Clear any previous errors
// Save to localStorage
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
} catch (error) {
setAvailableConversations([]);
setChatItems([]);
setIsStreaming(false);
// Extract error details for display
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
} finally {
setLoadingConversations(false);
}
@@ -856,11 +881,22 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setAvailableConversations([newConversation, ...useDevUIStore.getState().availableConversations]);
setChatItems([]);
setIsStreaming(false);
setConversationError(null); // Clear any previous errors
// Reset conversation usage by setting it to initial state
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
accumulatedTextRef.current = "";
} catch {
// Failed to create conversation
// Update localStorage cache with new conversation
const cachedKey = `devui_convs_${selectedAgent.id}`;
const updated = [newConversation, ...availableConversations];
localStorage.setItem(cachedKey, JSON.stringify(updated));
} catch (error) {
// Failed to create conversation - show error to user
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
}
}, [selectedAgent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
@@ -915,6 +951,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
[availableConversations, currentConversation, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
);
// Handle entity reload (hot reload)
const handleReloadEntity = useCallback(async () => {
if (isReloading || !selectedAgent) return;
setIsReloading(true);
const addToast = useDevUIStore.getState().addToast;
const updateAgent = useDevUIStore.getState().updateAgent;
try {
// Call backend reload endpoint
await apiClient.reloadEntity(selectedAgent.id);
// Fetch updated entity info
const updatedAgent = await apiClient.getAgentInfo(selectedAgent.id);
// Update store with fresh metadata
updateAgent(updatedAgent);
// Show success toast
addToast({
message: `${selectedAgent.name} has been reloaded successfully`,
type: "success",
});
} catch (error) {
// Show error toast
const errorMessage = error instanceof Error ? error.message : "Failed to reload entity";
addToast({
message: `Failed to reload: ${errorMessage}`,
type: "error",
duration: 6000,
});
} finally {
setIsReloading(false);
}
}, [isReloading, selectedAgent]);
// Handle conversation selection
const handleConversationSelect = useCallback(
async (conversationId: string) => {
@@ -1002,6 +1074,27 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const approval = pendingApprovals.find((a) => a.request_id === request_id);
if (!approval) return;
// Add user's decision as a visible message in the chat
const messageTimestamp = Math.floor(Date.now() / 1000);
const userDecisionMessage: import("@/types/openai").ConversationMessage = {
id: `user-approval-${Date.now()}`,
type: "message",
role: "user",
content: [
{
type: "function_approval_request",
request_id: request_id,
status: approved ? "approved" : "rejected",
function_call: approval.function_call,
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
],
status: "completed",
created_at: messageTimestamp,
};
const currentItems = useDevUIStore.getState().chatItems;
setChatItems([...currentItems, userDecisionMessage]);
// Create approval response in OpenAI-compatible format
const approvalInput: import("@/types/agent-framework").ResponseInputParam = [
{
@@ -1019,13 +1112,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
];
// Send approval response through the conversation
// We'll call handleSendMessage directly when invoked (it's defined below)
const request: RunAgentRequest = {
input: approvalInput,
conversation_id: currentConversation?.id,
};
// Remove from pending immediately (will be confirmed by backend event)
// Remove from pending immediately
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== request_id)
);
@@ -1039,6 +1131,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
async (request: RunAgentRequest) => {
if (!selectedAgent) return;
// Check if this is a function approval response (internal, don't show in chat)
const isApprovalResponse = request.input.some(
(inputItem) =>
inputItem.type === "message" &&
Array.isArray(inputItem.content) &&
inputItem.content.some((c) => c.type === "function_approval_response")
);
// Extract content from OpenAI format to create ConversationMessage
const messageContent: import("@/types/openai").MessageContent[] = [];
@@ -1069,16 +1169,23 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
}
}
// Add user message to UI state (OpenAI ConversationMessage)
const userMessage: import("@/types/openai").ConversationMessage = {
id: `user-${Date.now()}`,
type: "message",
role: "user",
content: messageContent,
status: "completed",
};
// Capture timestamp once for both user and assistant messages
const messageTimestamp = Math.floor(Date.now() / 1000); // Unix seconds
// Only add user message to UI if it's not an approval response (internal messages)
if (!isApprovalResponse && messageContent.length > 0) {
const userMessage: import("@/types/openai").ConversationMessage = {
id: `user-${Date.now()}`,
type: "message",
role: "user",
content: messageContent,
status: "completed",
created_at: messageTimestamp,
};
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
}
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
setIsStreaming(true);
// Create assistant message placeholder
@@ -1088,6 +1195,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
role: "assistant",
content: [], // Will be filled during streaming
status: "in_progress",
created_at: messageTimestamp,
};
setChatItems([...useDevUIStore.getState().chatItems, assistantMessage]);
@@ -1102,8 +1210,17 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
});
setCurrentConversation(conversationToUse);
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
} catch {
// Failed to create conversation
setConversationError(null); // Clear any previous errors
} catch (error) {
// Failed to create conversation - show error and stop execution
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
setIsSubmitting(false);
setIsStreaming(false);
return; // Stop execution - can't send message without conversation
}
}
@@ -1145,16 +1262,25 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
continue; // Continue processing other events
}
// Handle response.failed event
// Handle response.failed event (OpenAI standard)
if (openAIEvent.type === "response.failed") {
const failedEvent = openAIEvent as import("@/types/openai").ResponseFailedEvent;
const error = failedEvent.response?.error;
const errorMessage = error
? typeof error === "object" && "message" in error
? (error as any).message
: JSON.stringify(error)
: "Request failed";
// Format error message with details
let errorMessage = "Request failed";
if (error) {
if (typeof error === "object" && "message" in error) {
errorMessage = error.message as string;
if ("code" in error && error.code) {
errorMessage += ` (Code: ${error.code})`;
}
} else if (typeof error === "string") {
errorMessage = error;
}
}
// Update assistant message with error
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -1171,14 +1297,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
: item
));
setIsStreaming(false);
return;
return; // Exit stream processing on failure
}
// Handle function approval request events
if (openAIEvent.type === "response.function_approval.requested") {
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
// Add to pending approvals
// Add to pending approvals (for popup)
setPendingApprovals([
...useDevUIStore.getState().pendingApprovals,
{
@@ -1186,17 +1312,46 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
function_call: approvalEvent.function_call,
},
]);
continue; // Don't add approval requests to chat UI
// Also add to chat UI to show function call progress
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) => {
if (item.id === assistantMessage.id && item.type === "message") {
return {
...item,
content: [
...item.content,
{
type: "function_approval_request",
request_id: approvalEvent.request_id,
status: "pending",
function_call: approvalEvent.function_call,
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
],
status: "in_progress" as const,
};
}
return item;
}));
continue;
}
// Handle function approval response events
if (openAIEvent.type === "response.function_approval.responded") {
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
// Handle function result events (after function execution)
if (openAIEvent.type === "response.function_result.complete") {
const resultEvent = openAIEvent as import("@/types/openai").ResponseFunctionResultComplete;
// Remove from pending approvals
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
);
// Add function result as a separate conversation item for clear visibility
const functionResultItem: import("@/types/openai").ConversationFunctionCallOutput = {
id: `result-${Date.now()}`,
type: "function_call_output",
call_id: resultEvent.call_id,
output: resultEvent.output,
status: resultEvent.status === "completed" ? "completed" : "incomplete",
created_at: Math.floor(Date.now() / 1000),
};
const currentItems = useDevUIStore.getState().chatItems;
setChatItems([...currentItems, functionResultItem]);
continue;
}
@@ -1227,6 +1382,57 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
return; // Exit stream processing early on error
}
// Handle output item added events (images, files, data)
if (openAIEvent.type === "response.output_item.added") {
const outputItemEvent = openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent;
const item = outputItemEvent.item;
// Add output items to assistant message content
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((chatItem) => {
if (chatItem.id === assistantMessage.id && chatItem.type === "message") {
const existingContent = chatItem.content;
let newContent: import("@/types/openai").MessageContent | null = null;
// Map output items to message content
if (item.type === "output_image") {
newContent = {
type: "output_image",
image_url: item.image_url,
alt_text: item.alt_text,
mime_type: item.mime_type,
} as import("@/types/openai").MessageOutputImage;
} else if (item.type === "output_file") {
newContent = {
type: "output_file",
filename: item.filename,
file_url: item.file_url,
file_data: item.file_data,
mime_type: item.mime_type,
} as import("@/types/openai").MessageOutputFile;
} else if (item.type === "output_data") {
newContent = {
type: "output_data",
data: item.data,
mime_type: item.mime_type,
description: item.description,
} as import("@/types/openai").MessageOutputData;
}
// If we created new content, append it
if (newContent) {
return {
...chatItem,
content: [...existingContent, newContent],
status: "in_progress" as const,
};
}
}
return chatItem;
}));
continue; // Continue to next event
}
// Handle text delta events for chat
if (
openAIEvent.type === "response.output_text.delta" &&
@@ -1236,21 +1442,26 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
accumulatedTextRef.current += openAIEvent.delta;
// Update assistant message with accumulated content
// Preserve any existing non-text content (images, files, data)
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
}
: item
));
setChatItems(currentItems.map((item) => {
if (item.id === assistantMessage.id && item.type === "message") {
// Keep existing non-text content, update text content
const existingNonTextContent = item.content.filter(c => c.type !== "text");
return {
...item,
content: [
...existingNonTextContent,
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
};
}
return item;
}));
}
// Handle completion/error by detecting when streaming stops
@@ -1435,19 +1646,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
Chat with {selectedAgent.name || selectedAgent.id}
{oaiMode.enabled
? `Chat with ${oaiMode.model}`
: `Chat with ${selectedAgent.name || selectedAgent.id}`
}
</span>
</div>
</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View agent details"
>
<Info className="h-4 w-4" />
</Button>
{!oaiMode.enabled && uiMode === "developer" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View agent details"
>
<Info className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReloadEntity}
disabled={isReloading || selectedAgent.metadata?.source === "in_memory"}
className="h-6 w-6 p-0 flex-shrink-0"
title={
selectedAgent.metadata?.source === "in_memory"
? "In-memory entities cannot be reloaded"
: isReloading
? "Reloading..."
: "Reload entity code (hot reload)"
}
>
<RefreshCw className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`} />
</Button>
</>
)}
</div>
{/* Conversation Controls */}
@@ -1539,13 +1773,46 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</div>
</div>
{selectedAgent.description && (
{oaiMode.enabled ? (
<p className="text-sm text-muted-foreground">
{selectedAgent.description}
Using OpenAI model directly. Local agent tools and instructions are not applied.
</p>
) : (
selectedAgent.description && (
<p className="text-sm text-muted-foreground">
{selectedAgent.description}
</p>
)
)}
</div>
{/* Error Banner */}
{conversationError && (
<div className="mx-4 mt-2 p-3 bg-destructive/10 border border-destructive/30 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-destructive">
Failed to Create Conversation
</div>
<div className="text-xs text-destructive/90 mt-1 break-words">
{conversationError.message}
</div>
{conversationError.code && (
<div className="text-xs text-destructive/70 mt-1">
Error Code: {conversationError.code}
</div>
)}
</div>
<button
onClick={() => setConversationError(null)}
className="text-destructive hover:text-destructive/80 flex-shrink-0"
title="Dismiss error"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Messages */}
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
<div className="space-y-4">
@@ -11,6 +11,9 @@ import {
ChevronDown,
ChevronRight,
Music,
Check,
X,
Clock,
} from "lucide-react";
import type { MessageContent } from "@/types/openai";
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
@@ -37,12 +40,12 @@ function TextContentRenderer({ content, className, isStreaming }: ContentRendere
);
}
// Image content renderer
// Image content renderer (handles both input and output images)
function ImageContentRenderer({ content, className }: ContentRendererProps) {
const [imageError, setImageError] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "input_image") return null;
if (content.type !== "input_image" && content.type !== "output_image") return null;
const imageUrl = content.image_url;
@@ -77,9 +80,9 @@ function ImageContentRenderer({ content, className }: ContentRendererProps) {
);
}
// File content renderer
// File content renderer (handles both input and output files)
function FileContentRenderer({ content, className }: ContentRendererProps) {
if (content.type !== "input_file") return null;
if (content.type !== "input_file" && content.type !== "output_file") return null;
const fileUrl = content.file_url || content.file_data;
const filename = content.filename || "file";
@@ -156,6 +159,129 @@ function FileContentRenderer({ content, className }: ContentRendererProps) {
);
}
// Data content renderer (for generic structured data outputs)
function DataContentRenderer({ content, className }: ContentRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "output_data") return null;
const data = content.data;
const mimeType = content.mime_type;
const description = content.description;
// Try to parse as JSON for pretty printing
let displayData = data;
try {
const parsed = JSON.parse(data);
displayData = JSON.stringify(parsed, null, 2);
} catch {
// Not JSON, display as-is
}
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{description || "Data Output"}
</span>
<span className="text-xs text-muted-foreground ml-auto">{mimeType}</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
{isExpanded && (
<pre className="mt-2 text-xs overflow-auto max-h-64 bg-background p-2 rounded border font-mono">
{displayData}
</pre>
)}
</div>
);
}
// Function approval request renderer
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
if (content.type !== "function_approval_request") return null;
const [isExpanded, setIsExpanded] = useState(false);
const { status, function_call } = content;
// Status styling
const statusConfig = {
pending: {
icon: Clock,
color: "amber",
label: "Awaiting Approval",
bgClass: "bg-amber-50 dark:bg-amber-950/20",
borderClass: "border-amber-200 dark:border-amber-800",
iconClass: "text-amber-600 dark:text-amber-400",
textClass: "text-amber-800 dark:text-amber-300",
},
approved: {
icon: Check,
color: "green",
label: "Approved",
bgClass: "bg-green-50 dark:bg-green-950/20",
borderClass: "border-green-200 dark:border-green-800",
iconClass: "text-green-600 dark:text-green-400",
textClass: "text-green-800 dark:text-green-300",
},
rejected: {
icon: X,
color: "red",
label: "Rejected",
bgClass: "bg-red-50 dark:bg-red-950/20",
borderClass: "border-red-200 dark:border-red-800",
iconClass: "text-red-600 dark:text-red-400",
textClass: "text-red-800 dark:text-red-300",
},
};
const config = statusConfig[status];
const StatusIcon = config.icon;
let parsedArgs;
try {
parsedArgs = typeof function_call.arguments === "string"
? JSON.parse(function_call.arguments)
: function_call.arguments;
} catch {
parsedArgs = function_call.arguments;
}
return (
<div className={`my-2 p-3 border rounded ${config.bgClass} ${config.borderClass} ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<StatusIcon className={`h-4 w-4 ${config.iconClass}`} />
<span className={`text-sm font-medium ${config.textClass}`}>
{config.label}: {function_call.name}
</span>
{isExpanded ? (
<ChevronDown className={`h-4 w-4 ${config.iconClass} ml-auto`} />
) : (
<ChevronRight className={`h-4 w-4 ${config.iconClass} ml-auto`} />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className={`${config.textClass} mb-1`}>Arguments:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
)}
</div>
);
}
// Main content renderer that delegates to specific renderers
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
switch (content.type) {
@@ -164,9 +290,15 @@ export function OpenAIContentRenderer({ content, className, isStreaming }: Conte
case "output_text":
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
case "input_image":
case "output_image":
return <ImageContentRenderer content={content} className={className} />;
case "input_file":
case "output_file":
return <FileContentRenderer content={content} className={className} />;
case "output_data":
return <DataContentRenderer content={content} className={className} />;
case "function_approval_request":
return <FunctionApprovalRequestRenderer content={content} className={className} />;
default:
return null;
}
@@ -0,0 +1,528 @@
/**
* ExecutionTimeline - Vertical timeline showing workflow executor runs
* Features: Chronological executor execution, expandable output, bidirectional graph highlighting
*/
import { useState, useEffect, useMemo, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Loader2,
CheckCircle,
XCircle,
AlertCircle,
ChevronDown,
ChevronRight,
Copy,
Check,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
import type { ExecutorState } from "./executor-node";
import { truncateText } from "@/utils/workflow-utils";
interface ExecutorRun {
executorId: string;
executorName: string;
itemId: string; // Unique ID for this specific run
state: ExecutorState;
output: string;
error?: string;
timestamp: number;
runNumber: number; // For multiple runs of same executor
}
interface ExecutionTimelineProps {
events: ExtendedResponseStreamEvent[];
itemOutputs: Record<string, string>;
currentExecutorId: string | null;
isStreaming: boolean;
onExecutorClick?: (executorId: string) => void;
selectedExecutorId?: string | null;
workflowResult?: string;
}
function getStateIcon(state: ExecutorState) {
switch (state) {
case "running":
return <Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin" />;
case "completed":
return <CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />;
case "failed":
return <XCircle className="w-4 h-4 text-red-500 dark:text-red-400" />;
case "cancelled":
return <AlertCircle className="w-4 h-4 text-orange-500 dark:text-orange-400" />;
default:
return <div className="w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500" />;
}
}
function getStateBadgeClass(state: ExecutorState) {
switch (state) {
case "running":
return "bg-[#643FB2]/10 text-[#643FB2] dark:bg-[#8B5CF6]/10 dark:text-[#8B5CF6] border-[#643FB2]/20 dark:border-[#8B5CF6]/20";
case "completed":
return "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20";
case "failed":
return "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20";
case "cancelled":
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20";
default:
return "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20";
}
}
function ExecutorRunItem({
run,
isExpanded,
onToggle,
onClick,
isSelected,
}: {
run: ExecutorRun;
isExpanded: boolean;
onToggle: () => void;
onClick: () => void;
isSelected: boolean;
}) {
const timestamp = new Date(run.timestamp).toLocaleTimeString();
const hasOutput = run.output.trim().length > 0;
const canExpand = hasOutput || run.error;
const outputRef = useRef<HTMLPreElement>(null);
// Auto-scroll output to bottom when content changes (during streaming)
useEffect(() => {
if (isExpanded && run.state === "running" && outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [run.output, isExpanded, run.state]);
return (
<div
className={`border rounded-lg transition-all ${
isSelected
? "border-blue-500 dark:border-blue-400 bg-blue-500/5 dark:bg-blue-500/10"
: "border-border hover:border-muted-foreground/30"
}`}
>
{/* Header - Always Visible */}
<div
className="p-3 cursor-pointer"
onClick={() => {
onClick();
if (canExpand) onToggle();
}}
>
<div className="grid grid-cols-[auto_auto_1fr_auto] items-center gap-2 mb-1">
<div className="w-3 text-muted-foreground">
{canExpand && (
<>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</>
)}
</div>
<div>{getStateIcon(run.state)}</div>
<span className="font-medium text-sm truncate overflow-hidden">
{run.executorName}
</span>
{run.runNumber > 1 ? (
<Badge variant="outline" className="text-xs whitespace-nowrap">
Run #{run.runNumber}
</Badge>
) : (
<div></div>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-5">
<span className="font-mono">{timestamp}</span>
<Badge
variant="outline"
className={`text-xs border ${getStateBadgeClass(run.state)}`}
>
{run.state}
</Badge>
</div>
</div>
{/* Expandable Content */}
{isExpanded && canExpand && (
<div className="border-t px-3 py-2 bg-muted/30">
{run.error ? (
<div className="space-y-1">
<div className="text-xs font-medium text-red-600 dark:text-red-400">
Error:
</div>
<pre className="text-xs bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded p-2 overflow-y-auto overflow-x-hidden max-h-40 whitespace-pre-wrap break-all">
{run.error}
</pre>
</div>
) : (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
Output:
</div>
<pre
ref={outputRef}
className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all"
>
{run.output}
</pre>
</div>
)}
</div>
)}
</div>
);
}
export function ExecutionTimeline({
events,
itemOutputs,
currentExecutorId,
isStreaming,
onExecutorClick,
selectedExecutorId,
workflowResult,
}: ExecutionTimelineProps) {
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(new Set());
const [updateTrigger, setUpdateTrigger] = useState(0);
const [copied, setCopied] = useState(false);
const lastScrolledRunRef = useRef<string | null>(null);
const timelineEndRef = useRef<HTMLDivElement>(null);
// Force re-render when streaming to show updated outputs from itemOutputs ref
// Note: itemOutputs is a ref (not state), so changes don't trigger re-renders automatically.
// This polling approach ensures the UI updates during streaming. Could be optimized by:
// 1. Converting itemOutputs to state (increases re-renders)
// 2. Using requestAnimationFrame instead of setInterval
// 3. Having parent component trigger updates via callback
useEffect(() => {
if (isStreaming) {
const interval = setInterval(() => {
setUpdateTrigger((prev) => prev + 1);
}, 100); // Update 10 times per second during streaming
return () => clearInterval(interval);
}
}, [isStreaming]);
// Process events to extract executor runs - memoized to prevent recalculation
const { executorRuns, executorRunCount } = useMemo(() => {
const runs: ExecutorRun[] = [];
const runCount = new Map<string, number>();
events.forEach((event) => {
// Extract UI timestamp (captured when event arrived, won't change on re-render)
const uiTimestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? event._uiTimestamp * 1000
: Date.now();
// Handle new standard OpenAI events
if (event.type === "response.output_item.added") {
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; created_at?: number; metadata?: any } }).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && item.executor_id && item.id) {
const executorId = item.executor_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
// Handle message items from Magentic agents
const executorId = item.metadata.agent_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
}
}
// Handle completion events
if (event.type === "response.output_item.done") {
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; status?: string; error?: string; metadata?: any } }).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && item.executor_id && item.id) {
const itemId = item.id;
// Find the run by ITEM ID (not executor ID!) to handle multiple runs correctly
const existingRun = runs.find((r) => r.itemId === itemId);
if (existingRun) {
existingRun.state =
item.status === "completed"
? "completed"
: item.status === "failed"
? "failed"
: "completed";
// Use item-specific output, not executor-wide output
existingRun.output = itemOutputs[itemId] || "";
if (item.status === "failed" && item.error) {
existingRun.error = item.error;
}
}
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
// Handle message completion from Magentic agents
const itemId = item.id;
const existingRun = runs.find((r) => r.itemId === itemId);
if (existingRun) {
existingRun.state = item.status === "completed" ? "completed" : "failed";
existingRun.output = itemOutputs[itemId] || "";
}
}
}
// Fallback support for workflow_event format (used for unhandled event types and status/warning/error events)
if (
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
const data = event.data as { executor_id?: string; event_type?: string; data?: unknown; timestamp?: string };
const executorId = data.executor_id;
if (!executorId) return;
const eventType = data.event_type;
if (eventType === "ExecutorInvokedEvent") {
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
// Create synthetic item ID for fallback format (no real item.id from backend)
const syntheticItemId = `fallback_${executorId}_${uiTimestamp}`;
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId: syntheticItemId,
state: "running",
output: itemOutputs[syntheticItemId] || "",
timestamp: uiTimestamp,
runNumber,
});
} else if (eventType === "ExecutorCompletedEvent") {
// Find the most recent running instance of this executor (search from end)
let existingRun: ExecutorRun | undefined;
for (let i = runs.length - 1; i >= 0; i--) {
if (runs[i].executorId === executorId && runs[i].state === "running") {
existingRun = runs[i];
break;
}
}
if (existingRun) {
existingRun.state = "completed";
existingRun.output = itemOutputs[existingRun.itemId] || "";
}
} else if (
eventType?.includes("Error") ||
eventType?.includes("Failed")
) {
// Find the most recent running instance of this executor (search from end)
let existingRun: ExecutorRun | undefined;
for (let i = runs.length - 1; i >= 0; i--) {
if (runs[i].executorId === executorId && runs[i].state === "running") {
existingRun = runs[i];
break;
}
}
if (existingRun) {
existingRun.state = "failed";
existingRun.error =
typeof data.data === "string" ? data.data : "Execution failed";
}
}
}
});
// Update outputs for running executors using item-specific outputs
// This ensures each run gets its own output, even for multiple runs of the same executor
runs.forEach((run) => {
if (run.state === "running" && itemOutputs[run.itemId]) {
run.output = itemOutputs[run.itemId];
}
});
return { executorRuns: runs, executorRunCount: runCount };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [events, itemOutputs, updateTrigger]);
// Auto-expand running executors
useEffect(() => {
if (currentExecutorId) {
setExpandedRuns((prev) => {
const next = new Set(prev);
next.add(`${currentExecutorId}-${executorRunCount.get(currentExecutorId) || 1}`);
return next;
});
}
}, [currentExecutorId, executorRunCount]);
// Auto-scroll to newest executor when it appears or changes
useEffect(() => {
if (executorRuns.length > 0 && isStreaming) {
const latestRun = executorRuns[executorRuns.length - 1];
const latestRunKey = `${latestRun.executorId}-${latestRun.runNumber}`;
// Only scroll if this is a new run we haven't scrolled to yet
if (latestRunKey !== lastScrolledRunRef.current) {
lastScrolledRunRef.current = latestRunKey;
// Scroll to the end of the timeline
if (timelineEndRef.current) {
timelineEndRef.current.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}
}
}
}, [executorRuns, isStreaming]);
// Auto-scroll to show workflow result when it appears (after streaming completes)
useEffect(() => {
if (workflowResult && !isStreaming && timelineEndRef.current) {
// Small delay to ensure the result card is rendered before scrolling
setTimeout(() => {
timelineEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}, 100);
}
}, [workflowResult, isStreaming]);
const handleCopyAll = () => {
const text = executorRuns
.map((run) => {
const timestamp = new Date(run.timestamp).toLocaleTimeString();
const header = `[${timestamp}] ${run.executorName} (${run.state})`;
const content = run.error || run.output || "(no output)";
return `${header}\n${content}\n`;
})
.join("\n");
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="h-full flex flex-col border-l bg-muted/30">
{/* Header */}
<div className="p-3 border-b bg-background flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">Execution Timeline</span>
<Badge variant="outline" className="text-xs">
{executorRuns.length}
</Badge>
{isStreaming && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<div className="h-2 w-2 animate-pulse rounded-full bg-[#643FB2] dark:bg-[#8B5CF6]" />
<span>Running</span>
</div>
)}
</div>
{executorRuns.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleCopyAll}
className={`h-7 px-2 text-xs ${copied ? "text-green-600 dark:text-green-400" : ""}`}
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied!
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy All
</>
)}
</Button>
)}
</div>
{/* Timeline Content */}
<ScrollArea className="flex-1">
<div className="p-3 space-y-2">
{executorRuns.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
No executor runs yet. Start the workflow to see execution timeline.
</div>
) : (
executorRuns.map((run, index) => {
const runKey = `${run.executorId}-${run.runNumber}`;
return (
<ExecutorRunItem
key={`${runKey}-${index}`}
run={run}
isExpanded={expandedRuns.has(runKey)}
onToggle={() => {
setExpandedRuns((prev) => {
const next = new Set(prev);
if (next.has(runKey)) {
next.delete(runKey);
} else {
next.add(runKey);
}
return next;
});
}}
onClick={() => onExecutorClick?.(run.executorId)}
isSelected={selectedExecutorId === run.executorId}
/>
);
})
)}
{/* Workflow final output card */}
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && (
<div className="border rounded-lg border-green-500/40 bg-green-500/5 dark:bg-green-500/10">
<div className="p-3 bg-green-500/10 border-b border-green-500/20">
<div className="flex items-center gap-2 mb-1">
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />
<span className="font-medium text-sm">Workflow Complete</span>
</div>
</div>
<div className="border-t px-3 py-2 bg-muted/30">
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
Final Output:
</div>
<pre className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all">
{workflowResult}
</pre>
</div>
</div>
</div>
)}
{/* Invisible element at the end for scroll target */}
<div ref={timelineEndRef} />
</div>
</ScrollArea>
</div>
);
}
@@ -3,8 +3,10 @@ import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
Workflow,
Home,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { truncateText } from "@/utils/workflow-utils";
export type ExecutorState =
| "pending"
@@ -81,11 +83,13 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const details = [];
if (nodeData.error && typeof nodeData.error === "string") {
// Truncate error to first 150 characters for node display
const truncatedError = truncateText(nodeData.error, 150);
details.push(
<div key="error" className="mb-2">
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Error:</div>
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800">
{nodeData.error}
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words">
{truncatedError}
</div>
</div>
);
@@ -155,34 +159,32 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
isRunning ? config.glow : "shadow-sm",
)}
>
{/* Small circular handles */}
{!nodeData.isStartNode && (
<Handle
type="target"
position={targetPosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
{/* Small circular handles - always render both to support any edge configuration */}
<Handle
type="target"
position={targetPosition}
id="target"
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
{!nodeData.isEndNode && (
<Handle
type="source"
position={sourcePosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
<Handle
type="source"
position={sourcePosition}
id="source"
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
<div className="p-3">
{/* Header with icon and title */}
@@ -196,18 +198,16 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
<Workflow className="w-5 h-5 text-gray-300 dark:text-gray-400" />
)}
</div>
{/* Small status badge for running state */}
{isRunning && (
<div className={cn(
"absolute -top-1 -right-1 w-3 h-3 rounded-full animate-pulse",
config.badgeColor
)} />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{nodeData.name || nodeData.executorId}
</h3>
<div className="flex items-center gap-1.5">
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{nodeData.name || nodeData.executorId}
</h3>
{isRunning && (
<Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin flex-shrink-0" />
)}
</div>
{nodeData.executorType && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{nodeData.executorType}
@@ -0,0 +1,150 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MessageCircle, Send, Loader2 } from "lucide-react";
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
import type { JSONSchemaProperty } from "@/types";
interface HilRequest {
request_id: string;
request_data: Record<string, unknown>;
request_schema: JSONSchemaProperty;
}
interface HilInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
requests: HilRequest[];
responses: Record<string, Record<string, unknown>>;
onResponseChange: (requestId: string, values: Record<string, unknown>) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export function HilInputModal({
open,
onOpenChange,
requests,
responses,
onResponseChange,
onSubmit,
isSubmitting,
}: HilInputModalProps) {
// Check if all required fields are filled
const areAllRequiredFieldsFilled = () => {
return requests.every((req) => {
const response = responses[req.request_id] || {};
return validateSchemaForm(req.request_schema, response);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5" />
Workflow Requires Input ({requests.length} request
{requests.length > 1 ? "s" : ""})
</DialogTitle>
<DialogDescription>
The workflow is paused and needs your input to continue.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{requests.map((req, index) => (
<Card key={req.request_id}>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
Request {index + 1}
<Badge variant="outline" className="ml-2 font-mono text-xs">
{req.request_id.slice(0, 8)}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{/* Show request data as readonly context */}
{Object.keys(req.request_data).length > 0 && (
<div className="mb-4 p-3 bg-muted rounded-md max-h-48 overflow-y-auto">
<p className="text-xs font-medium text-muted-foreground mb-2">
Request Context:
</p>
<div className="space-y-1">
{Object.entries(req.request_data)
.filter(([key]) => !["request_id", "source_executor_id"].includes(key))
.map(([key, value]) => (
<div key={key} className="text-xs">
<span className="font-medium">{key}:</span>{" "}
<span className="text-muted-foreground break-all">
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</span>
</div>
))}
</div>
</div>
)}
{/* Show expected response hint if available */}
{req.request_schema?.description && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md">
<p className="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">
Expected Response:
</p>
<p className="text-xs text-blue-700 dark:text-blue-300">
{req.request_schema.description}
</p>
</div>
)}
{/* Use schema-based form renderer for RESPONSE (not request) */}
<SchemaFormRenderer
schema={req.request_schema}
values={responses[req.request_id] || {}}
onChange={(values) => onResponseChange(req.request_id, values)}
disabled={isSubmitting}
/>
</CardContent>
</Card>
))}
</div>
<DialogFooter>
<div className="flex gap-2 w-full justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={onSubmit}
disabled={isSubmitting || !areAllRequiredFieldsFilled()}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Submitting...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Submit & Continue
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -7,3 +7,5 @@ export { WorkflowDetailsModal } from "./workflow-details-modal";
export { WorkflowFlow } from "./workflow-flow";
export { WorkflowInputForm } from "./workflow-input-form";
export { ExecutorNode } from "./executor-node";
export { SchemaFormRenderer, validateSchemaForm, filterEmptyOptionalFields } from "./schema-form-renderer";
export { HilInputModal } from "./hil-input-modal";
@@ -0,0 +1,546 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { JSONSchemaProperty } from "@/types";
// ============================================================================
// Field Type Detection (from WorkflowInputForm)
// ============================================================================
function isShortField(fieldName: string): boolean {
const shortFieldNames = [
"name",
"title",
"id",
"key",
"label",
"type",
"status",
"tag",
"category",
"code",
"username",
"password",
"email",
];
return shortFieldNames.includes(fieldName.toLowerCase());
}
function shouldFieldBeTextarea(
fieldName: string,
schema: JSONSchemaProperty
): boolean {
return (
schema.format === "textarea" ||
(!!schema.description && schema.description.length > 100) ||
(schema.type === "string" && !schema.enum && !isShortField(fieldName))
);
}
function getFieldColumnSpan(
fieldName: string,
schema: JSONSchemaProperty
): string {
const isTextarea = shouldFieldBeTextarea(fieldName, schema);
const hasLongDescription =
!!schema.description && schema.description.length > 150;
if (isTextarea || hasLongDescription) {
return "md:col-span-2 lg:col-span-3 xl:col-span-4";
}
if (
schema.type === "array" ||
(!!schema.description && schema.description.length > 80)
) {
return "xl:col-span-2";
}
return "";
}
// ============================================================================
// ChatMessage Pattern Detection (from WorkflowInputForm)
// ============================================================================
function detectChatMessagePattern(
schema: JSONSchemaProperty,
requiredFields: string[]
): boolean {
if (schema.type !== "object" || !schema.properties) return false;
const properties = schema.properties;
const optionalFields = Object.keys(properties).filter(
(name) => !requiredFields.includes(name)
);
return (
requiredFields.includes("role") &&
optionalFields.some((f) => ["text", "message", "content"].includes(f)) &&
properties["role"]?.type === "string"
);
}
// ============================================================================
// Form Field Component (from WorkflowInputForm)
// ============================================================================
interface FormFieldProps {
name: string;
schema: JSONSchemaProperty;
value: unknown;
onChange: (value: unknown) => void;
isRequired?: boolean;
isReadOnly?: boolean; // NEW: for HIL display-only fields
}
function FormField({
name,
schema,
value,
onChange,
isRequired = false,
isReadOnly = false,
}: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
const isTextarea = shouldFieldBeTextarea(name, schema);
const renderInput = () => {
// Read-only display (for HIL request context)
if (isReadOnly) {
return (
<div className="space-y-2">
<Label htmlFor={name} className="text-muted-foreground">
{name}
</Label>
<div className="text-sm p-2 bg-muted rounded border">
{typeof value === "object"
? JSON.stringify(value, null, 2)
: String(value)}
</div>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
);
}
switch (type) {
case "string":
if (enumValues) {
// Enum select
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Select
value={
typeof value === "string" && value
? value
: typeof defaultValue === "string"
? defaultValue
: enumValues[0]
}
onValueChange={(val) => onChange(val)}
>
<SelectTrigger>
<SelectValue placeholder={`Select ${name}`} />
</SelectTrigger>
<SelectContent>
{enumValues.map((option: string) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else if (isTextarea) {
// Multi-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
rows={4}
className="min-w-[300px] w-full"
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else {
// Single-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
case "integer":
case "number":
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="number"
step={type === "integer" ? "1" : "any"}
value={typeof value === "number" ? value : ""}
onChange={(e) => {
const val =
type === "integer"
? parseInt(e.target.value)
: parseFloat(e.target.value);
onChange(isNaN(val) ? "" : val);
}}
placeholder={
typeof defaultValue === "number"
? defaultValue.toString()
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "boolean":
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked)}
/>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "array":
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
Array.isArray(value)
? value.join(", ")
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
const arrayValue = e.target.value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
onChange(arrayValue);
}}
placeholder="Enter items separated by commas"
rows={2}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "object":
default:
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
typeof value === "object" && value !== null
? JSON.stringify(value, null, 2)
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
onChange(parsed);
} catch {
onChange(e.target.value);
}
}}
placeholder='{"key": "value"}'
rows={3}
className="font-mono text-xs"
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
};
return <div className={getFieldColumnSpan(name, schema)}>{renderInput()}</div>;
}
// ============================================================================
// Main Schema Form Renderer Component
// ============================================================================
export interface SchemaFormRendererProps {
schema: JSONSchemaProperty;
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
disabled?: boolean;
readOnlyFields?: string[]; // NEW: Fields to display but not edit (for HIL)
hideFields?: string[]; // NEW: Fields to completely hide
showCollapsedByDefault?: boolean; // NEW: Control initial collapsed state
}
export function SchemaFormRenderer({
schema,
values,
onChange,
disabled = false,
readOnlyFields = [],
hideFields = [],
showCollapsedByDefault = false,
}: SchemaFormRendererProps) {
const [showAdvancedFields, setShowAdvancedFields] = useState(
showCollapsedByDefault
);
const properties = schema.properties || {};
const allFieldNames = Object.keys(properties).filter(
(name) => !hideFields.includes(name)
);
const requiredFields = (schema.required || []).filter(
(name) => !hideFields.includes(name)
);
// Detect ChatMessage pattern
const isChatMessageLike = detectChatMessagePattern(schema, requiredFields);
// Separate required and optional fields
const requiredFieldNames = allFieldNames.filter(
(name) =>
requiredFields.includes(name) && !(isChatMessageLike && name === "role")
);
const optionalFieldNames = allFieldNames.filter(
(name) => !requiredFields.includes(name)
);
// For ChatMessage: prioritize text/message/content
const sortedOptionalFields = isChatMessageLike
? [...optionalFieldNames].sort((a, b) => {
const priority = (name: string) =>
["text", "message", "content"].includes(name) ? 1 : 0;
return priority(b) - priority(a);
})
: optionalFieldNames;
// Show minimum visible fields
const MIN_VISIBLE_FIELDS = isChatMessageLike ? 1 : 6;
const visibleOptionalCount = Math.max(
0,
MIN_VISIBLE_FIELDS - requiredFieldNames.length
);
const visibleOptionalFields = sortedOptionalFields.slice(
0,
visibleOptionalCount
);
const collapsedOptionalFields = sortedOptionalFields.slice(
visibleOptionalCount
);
const hasCollapsedFields = collapsedOptionalFields.length > 0;
const hasRequiredFields = requiredFieldNames.length > 0;
const updateField = (fieldName: string, value: unknown) => {
onChange({
...values,
[fieldName]: value,
});
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={true}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
{/* Separator between required and optional */}
{hasRequiredFields && optionalFieldNames.length > 0 && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<div className="border-t border-border"></div>
</div>
)}
{/* Visible optional fields */}
{visibleOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
{/* Collapsed optional fields toggle */}
{hasCollapsedFields && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
className="w-full justify-center gap-2"
disabled={disabled}
>
{showAdvancedFields ? (
<>
<ChevronUp className="h-4 w-4" />
Hide {collapsedOptionalFields.length} optional field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show {collapsedOptionalFields.length} optional field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
)}
</Button>
</div>
)}
{/* Collapsed optional fields */}
{showAdvancedFields &&
collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
</div>
);
}
// ============================================================================
// Export helper functions for validation
// ============================================================================
export function validateSchemaForm(
schema: JSONSchemaProperty,
values: Record<string, unknown>
): boolean {
const requiredFields = schema.required || [];
return requiredFields.every((fieldName) => {
const value = values[fieldName];
return value !== undefined && value !== "" && value !== null;
});
}
export function filterEmptyOptionalFields(
schema: JSONSchemaProperty,
values: Record<string, unknown>
): Record<string, unknown> {
const requiredFields = schema.required || [];
const filtered: Record<string, unknown> = {};
Object.keys(values).forEach((key) => {
const value = values[key];
// Include if: 1) required field, OR 2) has non-empty value
if (
requiredFields.includes(key) ||
(value !== undefined && value !== "" && value !== null)
) {
filtered[key] = value;
}
});
return filtered;
}
@@ -8,6 +8,7 @@ import {
Shuffle,
Zap,
ArrowDown,
ArrowLeftRight,
} from "lucide-react";
import {
DropdownMenu,
@@ -39,6 +40,7 @@ import {
processWorkflowEvents,
updateNodesWithEvents,
updateEdgesWithSequenceAnalysis,
consolidateBidirectionalEdges,
type NodeUpdate,
} from "@/utils/workflow-utils";
import type { ExtendedResponseStreamEvent } from "@/types";
@@ -59,7 +61,7 @@ function ViewOptionsPanel({
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean; consolidateBidirectionalEdges: boolean };
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
layoutDirection: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
@@ -134,6 +136,16 @@ function ViewOptionsPanel({
</div>
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("consolidateBidirectionalEdges")}
>
<div className="flex items-center">
<ArrowLeftRight className="mr-2 h-4 w-4" />
Merge Bidirectional Edges
</div>
<Checkbox checked={viewOptions.consolidateBidirectionalEdges} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center justify-between"
@@ -192,12 +204,14 @@ interface WorkflowFlowProps {
showMinimap: boolean;
showGrid: boolean;
animateRun: boolean;
consolidateBidirectionalEdges: boolean;
};
onToggleViewOption?: (
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
) => void;
layoutDirection?: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
timelineVisible?: boolean;
}
// Animation handler component that runs inside ReactFlow context
@@ -248,16 +262,35 @@ function WorkflowAnimationHandler({
return null; // This component doesn't render anything
}
// Timeline resize handler component that runs inside ReactFlow context
const TimelineResizeHandler = memo(({ timelineVisible }: { timelineVisible: boolean }) => {
const { fitView } = useReactFlow();
// Trigger fitView when timeline visibility changes to adjust ReactFlow viewport
useEffect(() => {
// Delay fitView to let CSS transition complete (timeline animation is 300ms)
const timeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
}, 350); // Slightly longer than timeline animation duration
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineVisible]); // Only trigger when timelineVisible changes, not fitView reference
return null; // This component doesn't render anything
});
export const WorkflowFlow = memo(function WorkflowFlow({
workflowDump,
events,
isStreaming,
onNodeSelect,
className = "",
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
viewOptions = { showMinimap: false, showGrid: true, animateRun: true, consolidateBidirectionalEdges: true },
onToggleViewOption,
layoutDirection = "LR",
onLayoutDirectionChange,
timelineVisible = false,
}: WorkflowFlowProps) {
// Create initial nodes and edges from workflow dump
const { initialNodes, initialEdges } = useMemo(() => {
@@ -272,17 +305,22 @@ export const WorkflowFlow = memo(function WorkflowFlow({
);
const edges = convertWorkflowDumpToEdges(workflowDump);
// Apply bidirectional edge consolidation if enabled
const finalEdges = viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(edges)
: edges;
// Apply auto-layout if we have nodes and edges
const layoutedNodes =
nodes.length > 0
? applyDagreLayout(nodes, edges, layoutDirection)
? applyDagreLayout(nodes, finalEdges, layoutDirection)
: nodes;
return {
initialNodes: layoutedNodes,
initialEdges: edges,
initialEdges: finalEdges,
};
}, [workflowDump, onNodeSelect, layoutDirection]);
}, [workflowDump, onNodeSelect, layoutDirection, viewOptions.consolidateBidirectionalEdges]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
@@ -323,31 +361,38 @@ export const WorkflowFlow = memo(function WorkflowFlow({
currentEdges,
events
);
return updatedEdges;
// Apply consolidation if enabled (preserves updated styling from sequence analysis)
return viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(updatedEdges)
: updatedEdges;
});
} else {
// Reset all edges to default state when events are cleared
setEdges((currentEdges) =>
currentEdges.map((edge) => ({
setEdges((currentEdges) => {
const resetEdges = currentEdges.map((edge) => ({
...edge,
animated: false,
style: {
stroke: "#6b7280", // Gray
strokeWidth: 2,
},
}))
);
}));
// Apply consolidation if enabled
return viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(resetEdges)
: resetEdges;
});
}
}, [events, setEdges]);
}, [events, setEdges, viewOptions.consolidateBidirectionalEdges]);
// Initialize nodes only when workflow structure changes (not on state updates)
// Initialize nodes and edges when workflow structure OR consolidation setting changes
useEffect(() => {
if (initialNodes.length > 0) {
setNodes(initialNodes);
setEdges(initialEdges);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowDump]); // Only re-initialize when workflowDump changes
}, [workflowDump, viewOptions.consolidateBidirectionalEdges]); // Re-initialize when workflow or consolidation toggle changes
const onNodeClick = useCallback(
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
@@ -467,6 +512,7 @@ export const WorkflowFlow = memo(function WorkflowFlow({
isStreaming={isStreaming}
animateRun={viewOptions.animateRun}
/>
<TimelineResizeHandler timelineVisible={timelineVisible} />
<ViewOptionsPanel
workflowDump={workflowDump}
onNodeSelect={onNodeSelect}
@@ -0,0 +1,225 @@
/**
* Workflow Conversation Manager Component
* Handles conversation selection, creation, and deletion for workflow executions
*/
import React, { useEffect, useState, useCallback } from "react";
import { useDevUIStore } from "@/stores/devuiStore";
import { apiClient } from "@/services/api";
import { Trash2, Plus, Clock } from "lucide-react";
import type { WorkflowSession } from "@/types";
interface WorkflowSessionManagerProps {
workflowId: string;
onSessionChange?: (session: WorkflowSession | undefined) => void;
}
export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
workflowId,
onSessionChange,
}) => {
// Use individual selectors to avoid creating new objects on every render
const currentSession = useDevUIStore((state) => state.currentSession);
const availableSessions = useDevUIStore((state) => state.availableSessions);
const loadingSessions = useDevUIStore((state) => state.loadingSessions);
const setCurrentSession = useDevUIStore((state) => state.setCurrentSession);
const setAvailableSessions = useDevUIStore((state) => state.setAvailableSessions);
const setLoadingSessions = useDevUIStore((state) => state.setLoadingSessions);
const addSession = useDevUIStore((state) => state.addSession);
const removeSession = useDevUIStore((state) => state.removeSession);
const addToast = useDevUIStore((state) => state.addToast);
const runtime = useDevUIStore((state) => state.runtime);
const [creatingSession, setCreatingSession] = useState(false);
const [deletingSession, setDeletingSession] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setLoadingSessions(true);
try {
const response = await apiClient.listWorkflowSessions(workflowId);
// If no conversations exist, auto-create one (like agent conversations)
if (response.data.length === 0) {
console.log("No workflow conversations found, creating default conversation");
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
});
setAvailableSessions([newSession]);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "Default conversation created",
type: "success",
});
} else {
// Conversations exist - set available and auto-select the first one
setAvailableSessions(response.data);
// Auto-select first conversation if no current selection
if (!currentSession) {
const firstSession = response.data[0];
setCurrentSession(firstSession);
onSessionChange?.(firstSession);
}
}
} catch (error) {
console.error("Failed to load workflow conversations:", error);
// Silently handle for .NET backend (doesn't support conversations yet)
// Only show error for Python backend where this is unexpected
if (runtime !== "dotnet") {
addToast({
message: "Failed to load workflow conversations",
type: "error",
});
}
} finally {
setLoadingSessions(false);
}
}, [workflowId, currentSession, runtime, setLoadingSessions, setAvailableSessions, setCurrentSession, onSessionChange, addToast]);
// Load sessions on mount
useEffect(() => {
loadSessions();
}, [loadSessions]);
const handleCreateSession = async () => {
setCreatingSession(true);
try {
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
});
addSession(newSession);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "New conversation created",
type: "success",
});
} catch (error) {
console.error("Failed to create conversation:", error);
addToast({
message: "Failed to create conversation",
type: "error",
});
} finally {
setCreatingSession(false);
}
};
const handleSelectSession = (session: WorkflowSession) => {
setCurrentSession(session);
onSessionChange?.(session);
};
const handleDeleteSession = async (
sessionId: string,
event: React.MouseEvent
) => {
event.stopPropagation(); // Prevent session selection when clicking delete
if (!confirm("Delete this conversation? All checkpoints will be lost.")) {
return;
}
setDeletingSession(sessionId);
try {
await apiClient.deleteWorkflowSession(workflowId, sessionId);
removeSession(sessionId);
addToast({
message: "Conversation deleted",
type: "success",
});
} catch (error) {
console.error("Failed to delete conversation:", error);
addToast({
message: "Failed to delete conversation",
type: "error",
});
} finally {
setDeletingSession(null);
}
};
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp * 1000);
return date.toLocaleString();
};
if (loadingSessions) {
return (
<div className="flex items-center justify-center py-4">
<div className="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full" />
<span className="ml-2 text-sm text-gray-600">Loading sessions...</span>
</div>
);
}
return (
<div className="workflow-session-manager space-y-3">
{/* Header with Create Button */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Conversations
</h3>
<button
onClick={handleCreateSession}
disabled={creatingSession}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Create new conversation"
>
<Plus className="h-4 w-4" />
New Conversation
</button>
</div>
{/* Conversation List */}
{availableSessions.length === 0 ? (
<div className="text-center py-6 text-sm text-gray-500 dark:text-gray-400">
Loading conversations...
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{availableSessions.map((session) => (
<div
key={session.conversation_id}
onClick={() => handleSelectSession(session)}
className={`
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-all
${
currentSession?.conversation_id === session.conversation_id
? "bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
}
`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-400 flex-shrink-0" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{session.metadata.name || "Unnamed Conversation"}
</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(session.created_at)}
</div>
</div>
<button
onClick={(e) => handleDeleteSession(session.conversation_id, e)}
disabled={deletingSession === session.conversation_id}
className="ml-3 p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50"
title="Delete conversation"
>
{deletingSession === session.conversation_id ? (
<div className="animate-spin h-4 w-4 border-2 border-red-500 border-t-transparent rounded-full" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
))}
</div>
)}
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -4,14 +4,17 @@
*/
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EntitySelector } from "./entity-selector";
import { ModeToggle } from "@/components/mode-toggle";
import { Settings } from "lucide-react";
import { Settings, Zap } from "lucide-react";
import type { AgentInfo, WorkflowInfo } from "@/types";
import { useDevUIStore } from "@/stores";
interface AppHeaderProps {
agents: AgentInfo[];
workflows: WorkflowInfo[];
entities?: (AgentInfo | WorkflowInfo)[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onBrowseGallery?: () => void;
@@ -22,12 +25,15 @@ interface AppHeaderProps {
export function AppHeader({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
isLoading = false,
onSettingsClick,
}: AppHeaderProps) {
const { oaiMode } = useDevUIStore();
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="flex items-center gap-2 font-semibold">
@@ -58,15 +64,29 @@ export function AppHeader({
</defs>
</svg>
Dev UI
{/* Mode Badge */}
{oaiMode.enabled && (
<Badge variant="secondary" className="gap-1 ml-2">
<Zap className="h-3 w-3" />
OpenAI: {oaiMode.model}
</Badge>
)}
</div>
<EntitySelector
agents={agents}
workflows={workflows}
selectedItem={selectedItem}
onSelect={onSelect}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
{/* Show entity selector only when NOT in OAI mode */}
{!oaiMode.enabled && (
<EntitySelector
agents={agents}
workflows={workflows}
entities={entities}
selectedItem={selectedItem}
onSelect={onSelect}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
)}
<div className="flex-1"></div>
<div className="flex items-center gap-2 ml-auto">
<ModeToggle />
@@ -20,7 +20,6 @@ import {
ChevronRight,
ChevronDown,
Info,
PanelRightClose,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
@@ -95,7 +94,7 @@ interface TraceEventData extends EventDataBase {
interface DebugPanelProps {
events: ExtendedResponseStreamEvent[];
isStreaming?: boolean;
onClose?: () => void;
onMinimize?: () => void;
}
// Helper: Extract function result from DevUI custom event
@@ -116,39 +115,6 @@ function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
return null;
}
// Helper to get a stable timestamp for an event
// Uses event's own timestamp fields if available
function getEventTimestamp(event: ExtendedResponseStreamEvent): string {
// Priority 1: Check for top-level timestamp (DevUI custom events like function_result.complete)
if ('timestamp' in event && typeof event.timestamp === 'string') {
return new Date(event.timestamp).toLocaleTimeString();
}
// Priority 2: Check for nested data.timestamp (workflow/trace events)
if ('data' in event && event.data && typeof event.data === 'object' && 'timestamp' in event.data) {
const dataTimestamp = (event.data as any).timestamp;
if (typeof dataTimestamp === 'string') {
return new Date(dataTimestamp).toLocaleTimeString();
}
}
// Priority 3: Check for created_at in response object (lifecycle events)
if ('response' in event && event.response && typeof event.response === 'object' && 'created_at' in event.response) {
const createdAt = (event.response as any).created_at;
if (typeof createdAt === 'number') {
return new Date(createdAt * 1000).toLocaleTimeString();
}
}
// Fallback: use sequence number as label (better than showing same time for all)
if ('sequence_number' in event && typeof event.sequence_number === 'number') {
return `#${event.sequence_number}`;
}
// Last resort: hide timestamp by returning empty string
return '';
}
// Helper function to accumulate OpenAI events into meaningful units
function processEventsForDisplay(
events: ExtendedResponseStreamEvent[]
@@ -170,8 +136,8 @@ function processEventsForDisplay(
for (const event of events) {
// Skip trace events - they belong in the Traces tab only
if (
event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete"
event.type === "response.trace.completed" ||
event.type === "response.trace.completed"
) {
continue;
}
@@ -212,9 +178,9 @@ function processEventsForDisplay(
event.type === "response.completed" ||
event.type === "response.done" ||
event.type === "error" ||
event.type === "response.workflow_event.complete" ||
event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete" ||
event.type === "response.workflow_event.completed" ||
event.type === "response.trace.completed" ||
event.type === "response.trace.completed" ||
isFunctionResult
) {
// Flush any accumulated text before showing these events
@@ -228,8 +194,8 @@ function processEventsForDisplay(
// Extract function names from trace events
if (
(event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete") &&
(event.type === "response.trace.completed" ||
event.type === "response.trace.completed") &&
"data" in event
) {
const traceData = event.data as TraceEventData;
@@ -483,15 +449,14 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
return "Output item added";
}
case "response.workflow_event.complete":
case "response.workflow_event.completed":
if ("data" in event && event.data) {
const data = event.data as WorkflowEventData;
return `Executor: ${data.executor_id || "unknown"}`;
}
return "Workflow event";
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
if ("data" in event && event.data) {
const data = event.data as TraceEventData;
return `Trace: ${data.operation_name || "unknown"}`;
@@ -536,10 +501,9 @@ function getEventIcon(type: string) {
return CheckCircle2;
case "response.output_item.added":
return CheckCircle2;
case "response.workflow_event.complete":
case "response.workflow_event.completed":
return Activity;
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
return Search;
case "response.completed":
return CheckCircle2;
@@ -564,10 +528,9 @@ function getEventColor(type: string) {
return "text-green-600 dark:text-green-400";
case "response.output_item.added":
return "text-green-600 dark:text-green-400";
case "response.workflow_event.complete":
case "response.workflow_event.completed":
return "text-purple-600 dark:text-purple-400";
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
return "text-orange-600 dark:text-orange-400";
case "response.completed":
return "text-green-600 dark:text-green-400";
@@ -582,9 +545,15 @@ function getEventColor(type: string) {
function EventItem({ event }: EventItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
const Icon = getEventIcon(event.type);
const colorClass = getEventColor(event.type);
const timestamp = getEventTimestamp(event);
const eventType = event.type || "unknown";
const Icon = getEventIcon(eventType);
const colorClass = getEventColor(eventType);
// Use stored UI timestamp if available, otherwise compute from event data
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString();
const summary = getEventSummary(event);
// Determine if this event has expandable content
@@ -595,13 +564,13 @@ function EventItem({ event }: EventItemProps) {
event.type === "response.function_result.complete" ||
(event.type === "response.output_item.added" &&
getFunctionResultFromEvent(event) !== null) ||
(event.type === "response.workflow_event.complete" &&
(event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data) ||
(event.type === "response.trace_event.complete" &&
(event.type === "response.trace.completed" &&
"data" in event &&
event.data) ||
(event.type === "response.trace.complete" &&
(event.type === "response.trace.completed" &&
"data" in event &&
event.data) ||
(event.type === "response.output_text.delta" &&
@@ -620,7 +589,7 @@ function EventItem({ event }: EventItemProps) {
<Icon className={`h-3 w-3 ${colorClass}`} />
<span className="font-mono">{timestamp}</span>
<Badge variant="outline" className="text-xs py-0">
{event.type.replace("response.", "")}
{event.type ? event.type.replace("response.", "") : "unknown"}
</Badge>
</div>
@@ -859,7 +828,7 @@ function EventExpandedContent({
break;
}
case "response.workflow_event.complete":
case "response.workflow_event.completed":
if ("data" in event && event.data) {
const data = event.data as WorkflowEventData;
return (
@@ -915,8 +884,7 @@ function EventExpandedContent({
}
break;
case "response.trace_event.complete":
case "response.trace.complete":
case "response.trace.completed":
if ("data" in event && event.data) {
const data = event.data as TraceEventData;
return (
@@ -1193,8 +1161,8 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
// ONLY show actual trace events - handle both event type formats
const traceEvents = events.filter(
(e) =>
e.type === "response.trace_event.complete" ||
e.type === "response.trace.complete"
e.type === "response.trace.completed" ||
e.type === "response.trace.completed"
);
// Add separators between message rounds
@@ -1253,8 +1221,8 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const [isExpanded, setIsExpanded] = useState(false);
if (
(event.type !== "response.trace_event.complete" &&
event.type !== "response.trace.complete") ||
(event.type !== "response.trace.completed" &&
event.type !== "response.trace.completed") ||
!("data" in event)
) {
return (
@@ -1266,14 +1234,19 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const data = event.data as TraceEventData;
// Use actual trace timestamp if available, fallback to current time
let timestamp = new Date().toLocaleTimeString();
if (data.end_time) {
// Use stored UI timestamp first, then trace timestamps, then fallback to current time
let timestamp: string;
if ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number') {
// Use stored UI timestamp from when event was received
timestamp = new Date(event._uiTimestamp * 1000).toLocaleTimeString();
} else if (data.end_time) {
timestamp = new Date(data.end_time * 1000).toLocaleTimeString();
} else if (data.start_time) {
timestamp = new Date(data.start_time * 1000).toLocaleTimeString();
} else if (data.timestamp) {
timestamp = new Date(data.timestamp).toLocaleTimeString();
} else {
timestamp = new Date().toLocaleTimeString();
}
const operationName = data.operation_name || "Unknown Operation";
@@ -1520,7 +1493,10 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
}
function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const timestamp = getEventTimestamp(event);
// Use stored UI timestamp if available, otherwise compute from current time
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString();
// Check if this is a function call or result event
const isFunctionCall = event.type === "response.function_call.complete";
@@ -1621,7 +1597,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
export function DebugPanel({
events,
isStreaming = false,
onClose,
onMinimize,
}: DebugPanelProps) {
return (
<div className="flex-1 border-l flex flex-col min-h-0">
@@ -1638,15 +1614,15 @@ export function DebugPanel({
Tools
</TabsTrigger>
</TabsList>
{onClose && (
{onMinimize && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
onClick={onMinimize}
className="h-8 w-8 p-0 flex-shrink-0"
title="Hide debug panel"
title="Minimize debug panel"
>
<PanelRightClose className="h-4 w-4" />
<ChevronRight className="h-4 w-4" />
</Button>
)}
</div>
@@ -20,12 +20,18 @@ import {
Copy,
CheckCircle2,
ExternalLink,
Loader2,
AlertCircle,
} from "lucide-react";
import { useDevUIStore } from "@/stores";
import { apiClient } from "@/services/api";
import type { AgentInfo, WorkflowInfo } from "@/types";
interface DeploymentModalProps {
open: boolean;
onClose: () => void;
agentName?: string;
entity?: AgentInfo | WorkflowInfo;
}
type Tab = "docker" | "azure";
@@ -34,10 +40,108 @@ export function DeploymentModal({
open,
onClose,
agentName = "Agent",
entity,
}: DeploymentModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("docker");
// Get the Azure deployment feature flag from store
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
// Check if deployment is truly supported (both feature flag and backend support)
const deploymentSupported = azureDeploymentEnabled && (entity?.deployment_supported ?? false);
// Context-aware tab ordering: Azure first if deployable, Docker first otherwise
const [activeTab, setActiveTab] = useState<Tab>(
deploymentSupported ? "azure" : "docker"
);
const [copiedTemplate, setCopiedTemplate] = useState<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
// Deployment state from Zustand
const isDeploying = useDevUIStore((state) => state.isDeploying);
const deploymentLogs = useDevUIStore((state) => state.deploymentLogs);
const lastDeployment = useDevUIStore((state) => state.lastDeployment);
const startDeployment = useDevUIStore((state) => state.startDeployment);
const addDeploymentLog = useDevUIStore((state) => state.addDeploymentLog);
const setDeploymentResult = useDevUIStore((state) => state.setDeploymentResult);
const stopDeployment = useDevUIStore((state) => state.stopDeployment);
const clearDeploymentState = useDevUIStore((state) => state.clearDeploymentState);
// Generate Azure-compliant default app name from entity name
const generateDefaultAppName = (entityName: string) => {
// Convert to lowercase, replace spaces and underscores with hyphens
// Remove any non-alphanumeric characters except hyphens
// Ensure it starts with a letter and is under 32 chars
const cleaned = entityName
.toLowerCase()
.replace(/[_\s]+/g, '-') // Replace underscores and spaces with hyphens
.replace(/[^a-z0-9-]/g, '') // Remove any other special characters
.replace(/--+/g, '-') // Replace multiple hyphens with single
.replace(/^[^a-z]+/, '') // Remove non-letter prefix
.replace(/-$/, ''); // Remove trailing hyphen
// Ensure it starts with a letter, add 'app-' prefix if needed
const withPrefix = cleaned.match(/^[a-z]/) ? cleaned : `app-${cleaned}`;
// Truncate to 31 chars max (32 limit)
return withPrefix.substring(0, 31);
};
// Form state for deployment with smart defaults
const defaultAppName = entity ? generateDefaultAppName(entity.id) : "";
const [resourceGroup, setResourceGroup] = useState("my-test-rg");
const [appName, setAppName] = useState(defaultAppName);
const [region, setRegion] = useState("eastus");
const [appNameError, setAppNameError] = useState<string | null>(null);
// Update app name when entity changes or modal opens
useEffect(() => {
if (entity) {
const newDefaultName = generateDefaultAppName(entity.id);
setAppName(newDefaultName);
// Validate the default name
const error = validateAppName(newDefaultName);
setAppNameError(error);
}
}, [entity?.id]); // Only re-run when entity ID changes
// Auto-scroll deployment logs to bottom when new logs are added
useEffect(() => {
if (logsContainerRef.current && deploymentLogs.length > 0) {
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
}
}, [deploymentLogs]);
// Validate Azure Container App name
const validateAppName = (name: string): string | null => {
if (!name) return null; // Don't show error for empty field
// Check length
if (name.length >= 32) {
return "App name must be less than 32 characters";
}
// Check for valid characters (lowercase alphanumeric and hyphens only)
if (!/^[a-z0-9-]+$/.test(name)) {
return "App name must contain only lowercase letters, numbers, and hyphens (no underscores or uppercase)";
}
// Must start with a letter
if (!/^[a-z]/.test(name)) {
return "App name must start with a lowercase letter";
}
// Must end with alphanumeric
if (!/[a-z0-9]$/.test(name)) {
return "App name must end with a letter or number";
}
// Cannot have double hyphens
if (name.includes("--")) {
return "App name cannot contain consecutive hyphens (--)";
}
return null;
};
// Cleanup timeout on unmount
useEffect(() => {
@@ -48,6 +152,48 @@ export function DeploymentModal({
};
}, []);
const handleDeploy = async () => {
if (!entity?.id || !resourceGroup || !appName) return;
// Trim whitespace from inputs
const trimmedResourceGroup = resourceGroup.trim();
const trimmedAppName = appName.trim();
// Validate trimmed app name before deployment
const nameError = validateAppName(trimmedAppName);
if (nameError) {
setAppNameError(nameError);
return;
}
try {
startDeployment();
for await (const event of apiClient.streamDeployment({
entity_id: entity.id,
resource_group: trimmedResourceGroup,
app_name: trimmedAppName,
region,
ui_mode: "user",
})) {
addDeploymentLog(event.message);
if (event.type === "deploy.completed" && event.url && event.auth_token) {
setDeploymentResult({
url: event.url,
authToken: event.auth_token,
});
} else if (event.type === "deploy.failed") {
// Stop deploying but keep logs visible
stopDeployment();
}
}
} catch (error) {
addDeploymentLog(`Error: ${error instanceof Error ? error.message : "Deployment failed"}`);
stopDeployment();
}
};
const handleCopy = async (template: string, templateName: string) => {
try {
await navigator.clipboard.writeText(template);
@@ -64,8 +210,7 @@ export function DeploymentModal({
timeoutRef.current = null;
}, 2000);
} catch (err) {
console.error("Failed to copy template:", err);
// Reset state on error
// Reset state on error - clipboard write failed
setCopiedTemplate(null);
}
};
@@ -149,20 +294,22 @@ openai>=1.0.0
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => setActiveTab("azure")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "azure"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Cloud className="h-4 w-4 mr-2 inline" />
Azure
{activeTab === "azure" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
{deploymentSupported && (
<button
onClick={() => setActiveTab("azure")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "azure"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Cloud className="h-4 w-4 mr-2 inline" />
Azure
{activeTab === "azure" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
)}
</div>
{/* Tab Content */}
@@ -360,34 +507,230 @@ openai>=1.0.0
Deploy to Azure Container Apps
</h3>
<p className="text-sm text-muted-foreground">
Azure Container Apps provides serverless containers with
auto-scaling and integrated monitoring.
{deploymentSupported
? "One-click deployment to Azure with automatic containerization and authentication."
: "Azure Container Apps provides serverless containers with auto-scaling and integrated monitoring."}
</p>
</div>
{/* Prerequisites */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm">Prerequisites</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
<li>Azure subscription</li>
<li>
Azure CLI installed (
<code className="bg-muted px-1 rounded">
az --version
</code>
)
</li>
{/* Prerequisites Notice */}
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
<h4 className="text-sm font-semibold mb-2 text-blue-900 dark:text-blue-100">
Prerequisites for Azure Deployment
</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-blue-800 dark:text-blue-200">
<li>Azure CLI installed and authenticated (<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">az login</code>)</li>
<li>Docker installed and running</li>
<li>
Logged in to Azure:{" "}
<code className="bg-muted px-1 rounded">az login</code>
<li>Azure subscription with the following providers registered:
<ul className="ml-4 mt-1 space-y-0.5">
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.App</code> (Container Apps)</li>
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.ContainerRegistry</code> (ACR)</li>
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.OperationalInsights</code> (Logging)</li>
</ul>
</li>
</ul>
<details className="mt-2">
<summary className="text-xs cursor-pointer hover:underline text-blue-700 dark:text-blue-300">
How to register providers?
</summary>
<div className="mt-2 p-2 bg-blue-100 dark:bg-blue-900 rounded text-xs">
<p className="mb-1">Run these commands once per subscription:</p>
<code className="block font-mono">
az provider register -n Microsoft.App --wait<br/>
az provider register -n Microsoft.ContainerRegistry --wait<br/>
az provider register -n Microsoft.OperationalInsights --wait
</code>
</div>
</details>
</div>
{/* Step-by-step */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Deployment Steps</h4>
{/* Functional Deployment Form (only if supported) */}
{deploymentSupported && entity && !lastDeployment && (
<div className="border rounded-lg p-4 space-y-4">
{!isDeploying ? (
<>
<div className="space-y-3">
<div>
<label className="text-sm font-medium">Resource Group</label>
<input
type="text"
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
placeholder="my-test-rg"
value={resourceGroup}
onChange={(e) => setResourceGroup(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium">App Name</label>
<input
type="text"
className={`w-full mt-1 px-3 py-2 border rounded-md text-sm ${
appNameError ? "border-red-500" : ""
}`}
placeholder="my-agent-app"
value={appName}
onChange={(e) => {
const newName = e.target.value;
setAppName(newName);
// Validate on change to provide immediate feedback
// Trim for validation to match what will be sent
const error = validateAppName(newName.trim());
setAppNameError(error);
}}
/>
{appNameError && (
<p className="mt-1 text-xs text-red-600">{appNameError}</p>
)}
</div>
<div>
<label className="text-sm font-medium">Region</label>
<select
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
value={region}
onChange={(e) => setRegion(e.target.value)}
>
<option value="eastus">East US</option>
<option value="westus">West US</option>
<option value="westeurope">West Europe</option>
<option value="eastasia">East Asia</option>
</select>
</div>
</div>
<Button
onClick={handleDeploy}
disabled={!resourceGroup || !appName || !!appNameError}
className="w-full"
>
<Rocket className="h-4 w-4 mr-2" />
Deploy to Azure
</Button>
</>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Loader2 className="h-4 w-4 animate-spin" />
Deploying...
</div>
<div
ref={logsContainerRef}
className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1"
>
{deploymentLogs.map((log, i) => (
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
))}
</div>
</div>
)}
{/* Show logs after deployment stops (success or failure) */}
{!isDeploying && deploymentLogs.length > 0 && !lastDeployment && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-red-600">
<AlertCircle className="h-4 w-4" />
Deployment Failed
</div>
<div className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1">
{deploymentLogs.map((log, i) => (
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
))}
</div>
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
Try Again
</Button>
</div>
)}
</div>
)}
{/* Success Screen */}
{lastDeployment && (
<div className="border-2 border-green-200 bg-green-50 dark:bg-green-950/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<h4 className="font-semibold text-green-900 dark:text-green-100">
Deployment Successful!
</h4>
</div>
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-green-800 dark:text-green-200">
Deployment URL
</label>
<div className="flex gap-2 mt-1">
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm">
{lastDeployment.url}
</code>
<Button
size="sm"
variant="outline"
onClick={() => window.open(lastDeployment.url, "_blank")}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<label className="text-xs font-medium text-green-800 dark:text-green-200">
Auth Token (save this - shown only once)
</label>
<div className="flex gap-2 mt-1">
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm font-mono">
{lastDeployment.authToken}
</code>
<Button
size="sm"
variant="outline"
onClick={() => navigator.clipboard.writeText(lastDeployment.authToken)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
Deploy Another
</Button>
</div>
)}
{/* Deployment Not Supported Warning */}
{!deploymentSupported && entity?.deployment_reason && (
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 text-amber-600 flex-shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-200">
<strong>Deployment not available:</strong> {entity.deployment_reason}
</div>
</div>
</div>
)}
{/* CLI Instructions (only show when deployment not supported) */}
{!deploymentSupported && (
<>
{/* Prerequisites */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm">Prerequisites</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
<li>Azure subscription</li>
<li>
Azure CLI installed (
<code className="bg-muted px-1 rounded">
az --version
</code>
)
</li>
<li>Docker installed and running</li>
<li>
Logged in to Azure:{" "}
<code className="bg-muted px-1 rounded">az login</code>
</li>
</ul>
</div>
{/* Step-by-step */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Deployment Steps</h4>
<div className="space-y-3">
{/* Step 1 */}
@@ -508,6 +851,8 @@ az acr build --registry myregistry \\
</a>
</Button>
</div>
</>
)}
</div>
)}
</div>
@@ -20,6 +20,7 @@ import type { AgentInfo, WorkflowInfo } from "@/types";
interface EntitySelectorProps {
agents: AgentInfo[];
workflows: WorkflowInfo[];
entities?: (AgentInfo | WorkflowInfo)[]; // Full list in backend order
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onBrowseGallery?: () => void;
@@ -33,6 +34,7 @@ const getTypeIcon = (type: "agent" | "workflow") => {
export function EntitySelector({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
@@ -40,9 +42,8 @@ export function EntitySelector({
}: EntitySelectorProps) {
const [open, setOpen] = useState(false);
const allItems = [...agents, ...workflows].sort(
(a, b) => a.name?.localeCompare(b.name || a.id) || a.id.localeCompare(b.id)
);
// Use entities if provided (preserves backend order), otherwise combine agents and workflows
const allItems = entities || [...agents, ...workflows];
const handleSelect = (item: AgentInfo | WorkflowInfo) => {
onSelect(item);
@@ -82,80 +83,125 @@ export function EntitySelector({
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80 font-mono">
{agents.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Agents ({agents.length})
</DropdownMenuLabel>
{agents.map((agent) => {
const isAgentLoaded = agent.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={agent.id}
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full gap-2">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(agent)}
>
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{agent.name || agent.id}
</span>
{isAgentLoaded && agent.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{agent.description}
</div>
)}
</div>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Show items in backend order but with type grouping for clarity */}
{(() => {
// Group items by type while preserving order within each group
const workflowItems = allItems.filter(item => item.type === "workflow");
const agentItems = allItems.filter(item => item.type === "agent");
{workflows.length > 0 && (
<>
{agents.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflows.length})
</DropdownMenuLabel>
{workflows.map((workflow) => {
const isWorkflowLoaded = workflow.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={workflow.id}
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full gap-2">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(workflow)}
>
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{workflow.name || workflow.id}
</span>
{isWorkflowLoaded && workflow.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{workflow.description}
// Determine which type appears first in backend order
const firstItemType = allItems[0]?.type;
return (
<>
{/* Show workflows first if they appear first, otherwise agents */}
{firstItemType === "workflow" && workflowItems.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflowItems.length})
</DropdownMenuLabel>
{workflowItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
</div>
)}
</div>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Separator if both types exist */}
{workflowItems.length > 0 && agentItems.length > 0 && <DropdownMenuSeparator />}
{/* Agents section */}
{agentItems.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Agents ({agentItems.length})
</DropdownMenuLabel>
{agentItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Show workflows last if agents appear first */}
{firstItemType === "agent" && workflowItems.length > 0 && (
<>
{agentItems.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflowItems.length})
</DropdownMenuLabel>
{workflowItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
</>
);
})()}
{allItems.length === 0 && (
<DropdownMenuItem disabled>
@@ -13,7 +13,9 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ExternalLink, RotateCcw } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { ExternalLink, RotateCcw, Info, ChevronRight } from "lucide-react";
import { useDevUIStore } from "@/stores";
interface SettingsModalProps {
open: boolean;
@@ -21,10 +23,26 @@ interface SettingsModalProps {
onBackendUrlChange?: (url: string) => void;
}
type Tab = "about" | "settings";
type Tab = "general" | "proxy" | "about";
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("settings");
// Preset OpenAI models for quick selection
const PRESET_MODELS = [
"gpt-4.1",
"gpt-4.1-mini",
"o1",
"o1-mini",
"o3-mini",
] as const;
export function SettingsModal({
open,
onOpenChange,
onBackendUrlChange,
}: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("general");
// OpenAI proxy mode, Azure deployment, auth status, and server capabilities from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities } = useDevUIStore();
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
@@ -33,6 +51,10 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
});
const [tempUrl, setTempUrl] = useState(backendUrl);
// Auth token state
const [authTokenStored, setAuthTokenStored] = useState(!!localStorage.getItem("devui_auth_token"));
const [newAuthToken, setNewAuthToken] = useState("");
const handleSave = () => {
// Validate URL format
try {
@@ -59,33 +81,68 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
window.location.reload();
};
const handleAuthTokenSave = () => {
if (!newAuthToken.trim()) return;
localStorage.setItem("devui_auth_token", newAuthToken.trim());
setAuthTokenStored(true);
setNewAuthToken("");
// Reload to apply the auth token
window.location.reload();
};
const handleClearAuthToken = () => {
localStorage.removeItem("devui_auth_token");
setAuthTokenStored(false);
setNewAuthToken("");
// Reload to clear auth state
window.location.reload();
};
const isModified = tempUrl !== backendUrl;
const isDefault = !localStorage.getItem("devui_backend_url");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[600px] max-w-[90vw]">
<DialogHeader className="p-6 pb-2">
<DialogContent className="w-[600px] max-w-[90vw] flex flex-col max-h-[85vh]">
<DialogHeader className="p-6 pb-2 flex-shrink-0">
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<DialogClose onClose={() => onOpenChange(false)} />
{/* Tabs */}
<div className="flex border-b px-6">
<div className="flex border-b px-6 flex-shrink-0">
<button
onClick={() => setActiveTab("settings")}
onClick={() => setActiveTab("general")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "settings"
activeTab === "general"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Settings
{activeTab === "settings" && (
General
{activeTab === "general" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
{serverCapabilities.openai_proxy && (
<button
onClick={() => setActiveTab("proxy")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "proxy"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
OpenAI Proxy
{activeTab === "proxy" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
)}
<button
onClick={() => setActiveTab("about")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
@@ -101,9 +158,9 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
</button>
</div>
{/* Tab Content */}
<div className="px-6 pb-6 min-h-[240px]">
{activeTab === "settings" && (
{/* Tab Content - Scrollable with min-height */}
<div className="px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]">
{activeTab === "general" && (
<div className="space-y-6 pt-4">
{/* Backend URL Setting */}
<div className="space-y-3">
@@ -142,11 +199,7 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
<div className="flex gap-2 pt-2 min-h-[36px]">
{isModified && (
<>
<Button
onClick={handleSave}
size="sm"
className="flex-1"
>
<Button onClick={handleSave} size="sm" className="flex-1">
Apply & Reload
</Button>
<Button
@@ -161,6 +214,373 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
)}
</div>
</div>
{/* Auth Token Setting - Only show if backend requires auth OR token is already stored */}
{(authRequired || authTokenStored) && (
<div className="space-y-3 border-t pt-6">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Authentication Token
</Label>
{!authRequired && authTokenStored && (
<span className="text-xs text-muted-foreground">
(Not required by current backend)
</span>
)}
</div>
{authTokenStored ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
type="password"
value="••••••••••••••••••••"
disabled
className="font-mono text-sm flex-1"
/>
<Button
variant="destructive"
size="sm"
onClick={handleClearAuthToken}
className="flex-shrink-0"
>
Clear
</Button>
</div>
<p className="text-xs text-green-600 dark:text-green-400">
Token configured and stored locally
</p>
</div>
) : (
<div className="space-y-3">
<Input
type="password"
value={newAuthToken}
onChange={(e) => setNewAuthToken(e.target.value)}
placeholder="Enter bearer token"
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && newAuthToken.trim()) {
handleAuthTokenSave();
}
}}
/>
<Button
onClick={handleAuthTokenSave}
size="sm"
disabled={!newAuthToken.trim()}
className="w-full"
>
Save & Reload
</Button>
<p className="text-xs text-muted-foreground">
{authRequired
? "Required by backend (started with --auth flag)"
: "Not required by current backend"}
</p>
</div>
)}
</div>
)}
{/* Deployment Setting - Only show if backend supports deployment */}
{serverCapabilities.deployment && (
<div className="space-y-3 border-t pt-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
Azure Deployment
</Label>
<p className="text-xs text-muted-foreground">
Enable one-click deployment to Azure Container Apps
</p>
</div>
<Switch
checked={azureDeploymentEnabled}
onCheckedChange={setAzureDeploymentEnabled}
/>
</div>
{/* Expandable info section */}
<details className="group">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
Learn more about Azure deployment
</summary>
<div className="mt-3 space-y-3 pl-4">
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, agents that support deployment will show a "Deploy to Azure"
button. This allows you to deploy your agent to Azure Container Apps directly
from DevUI.
</p>
<div className="space-y-1.5">
<p className="text-xs font-medium">When enabled:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>Shows "Deploy to Azure" for supported agents</li>
<li>Requires Azure CLI and proper authentication</li>
<li>Backend must have deployment capabilities enabled</li>
</ul>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium">When disabled:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>Shows "Deployment Guide" for all agents</li>
<li>Provides Docker templates and manual deployment instructions</li>
<li>No backend deployment capabilities required</li>
</ul>
</div>
</div>
</details>
</div>
)}
</div>
)}
{activeTab === "proxy" && serverCapabilities.openai_proxy && (
<div className="space-y-6 pt-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base font-medium">
OpenAI Proxy Mode
</Label>
<p className="text-xs text-muted-foreground">
Route requests through DevUI backend to OpenAI API
</p>
</div>
<Switch
checked={oaiMode.enabled}
onCheckedChange={(checked: boolean) =>
setOAIMode({ ...oaiMode, enabled: checked })
}
/>
</div>
{/* Info box when disabled - prominent */}
{!oaiMode.enabled && (
<div className="bordder border-muted bg-muted/30 rounded-lg p-4 space-y-3">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400" />
<div className="space-y-2">
<p className="text-sm font-medium">
About OpenAI Proxy Mode
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, your chat requests are sent to your
DevUI backend{" "}
<span className="font-mono font-semibold">
({backendUrl})
</span>
, which then forwards them to OpenAI's API. This keeps
your{" "}
<span className="font-mono font-semibold">
OPENAI_API_KEY
</span>{" "}
secure on the server instead of exposing it in the
browser.
</p>
<div className="space-y-1.5 pt-1">
<p className="text-xs font-medium">Requirements:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>
Backend must have{" "}
<span className="font-mono">OPENAI_API_KEY</span>{" "}
configured
</li>
<li>
Backend must support OpenAI Responses API proxying
(DevUI does)
</li>
</ul>
</div>
<div className="space-y-1.5 pt-1">
<p className="text-xs font-medium">Why use this?</p>
<p className="text-xs text-muted-foreground">
Quickly test and compare OpenAI models directly
through the DevUI interface without creating custom
agents or exposing API keys in the browser.
</p>
</div>
</div>
</div>
</div>
)}
{oaiMode.enabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
{/* Model ID Input - Primary control */}
<div className="space-y-2">
<Label className="text-sm font-medium">Model</Label>
<Input
type="text"
value={oaiMode.model}
onChange={(e) =>
setOAIMode({ ...oaiMode, model: e.target.value })
}
placeholder="gpt-4.1-mini"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Enter any OpenAI model ID (e.g., gpt-4.1, o1, o3-mini)
</p>
</div>
{/* Quick Preset Buttons */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Common presets
</Label>
<div className="flex flex-wrap gap-2">
{PRESET_MODELS.map((model) => (
<Button
key={model}
variant={
oaiMode.model === model ? "default" : "outline"
}
size="sm"
onClick={() => setOAIMode({ ...oaiMode, model })}
className="text-xs h-7"
>
{model}
</Button>
))}
</div>
</div>
{/* Advanced Parameters */}
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
Advanced Parameters (optional)
</summary>
<div className="space-y-3 mt-3 pl-4">
{/* Temperature */}
<div className="space-y-1">
<Label className="text-xs">Temperature</Label>
<Input
type="number"
step="0.1"
min="0"
max="2"
value={oaiMode.temperature ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
temperature: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder="1.0 (default)"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Controls randomness (0-2)
</p>
</div>
{/* Max Output Tokens */}
<div className="space-y-1">
<Label className="text-xs">Max Output Tokens</Label>
<Input
type="number"
min="1"
value={oaiMode.max_output_tokens ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
max_output_tokens: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
placeholder="Auto"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Maximum tokens in response
</p>
</div>
{/* Top P */}
<div className="space-y-1">
<Label className="text-xs">Top P</Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
value={oaiMode.top_p ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
top_p: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder="1.0 (default)"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Nucleus sampling (0-1)
</p>
</div>
{/* Reasoning Effort */}
<div className="space-y-1">
<Label className="text-xs">Reasoning Effort (o-series models)</Label>
<select
value={oaiMode.reasoning_effort ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
reasoning_effort: e.target.value
? (e.target.value as "minimal" | "low" | "medium" | "high")
: undefined,
})
}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">Auto (default)</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<p className="text-xs text-muted-foreground">
Constrains reasoning effort (faster/cheaper vs thorough)
</p>
</div>
</div>
</details>
</div>
)}
</div>
{/* Collapsed info at bottom when enabled */}
{oaiMode.enabled && (
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded">
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p>
Requests route through{" "}
<span className="font-mono font-semibold">
{backendUrl}
</span>{" "}
to OpenAI API. Server must have{" "}
<span className="font-mono font-semibold">
OPENAI_API_KEY
</span>{" "}
configured.
</p>
</div>
</div>
)}
</div>
)}
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
@@ -1,126 +0,0 @@
import { useMemo, useState, useCallback } from "react";
import type { ExtendedResponseStreamEvent } from "@/types";
// import type { ExecutorNodeData } from "@/components/workflow/executor-node";
// Type for executor input/output data - can be various types based on workflow events
export type ExecutorData =
| string
| number
| boolean
| Record<string, unknown>
| null;
// State tracking for a specific executor
interface ExecutorState {
executorId: string;
state: "pending" | "running" | "completed" | "failed" | "cancelled";
inputData?: ExecutorData;
outputData?: ExecutorData;
error?: string;
timestamp: string;
}
interface WorkflowEventCorrelationResult {
// State access
isWorkflowRunning: boolean;
selectedExecutorId: string | null;
recentlyActive: string[];
// Actions
selectExecutor: (executorId: string) => void;
getExecutorData: (executorId: string) => ExecutorState | null;
getExecutorEvents: (executorId: string) => ExtendedResponseStreamEvent[];
}
// Hook for correlating workflow events with executor states
export function useWorkflowEventCorrelation(
events: ExtendedResponseStreamEvent[],
isStreaming: boolean
): WorkflowEventCorrelationResult {
const [selectedExecutorId, setSelectedExecutorId] = useState<string | null>(null);
// Process events into executor states
const { executors, recentlyActive, isWorkflowRunning } = useMemo(() => {
const executorMap: Record<string, ExecutorState> = {};
const activeExecutors: string[] = [];
let workflowActive = isStreaming;
// Process workflow events
events.forEach((event) => {
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
const data = event.data as any;
const executorId = data.executor_id;
if (!executorId) return;
// Initialize executor if not exists
if (!executorMap[executorId]) {
executorMap[executorId] = {
executorId,
state: "pending",
timestamp: new Date().toISOString(),
};
}
const executor = executorMap[executorId];
const eventType = data.event_type;
// Update state based on event type
if (eventType === "ExecutorInvokedEvent") {
executor.state = "running";
executor.inputData = data.data;
if (!activeExecutors.includes(executorId)) {
activeExecutors.push(executorId);
}
} else if (eventType === "ExecutorCompletedEvent") {
executor.state = "completed";
executor.outputData = data.data;
} else if (eventType?.includes("Error") || eventType?.includes("Failed")) {
executor.state = "failed";
executor.error = typeof data.data === "string" ? data.data : "Execution failed";
} else if (eventType?.includes("Cancel")) {
executor.state = "cancelled";
}
executor.timestamp = new Date().toISOString();
}
});
return {
executors: executorMap,
recentlyActive: activeExecutors.slice(-3), // Keep last 3 active executors
isWorkflowRunning: workflowActive,
};
}, [events, isStreaming]);
const selectExecutor = useCallback((executorId: string) => {
setSelectedExecutorId(executorId);
}, []);
const getExecutorData = useCallback((executorId: string): ExecutorState | null => {
return executors[executorId] || null;
}, [executors]);
const getExecutorEvents = useCallback(
(executorId: string): ExtendedResponseStreamEvent[] => {
return events.filter((event) => {
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
const data = event.data as any;
return data.executor_id === executorId;
}
return false;
});
},
[events]
);
return {
isWorkflowRunning,
selectedExecutorId,
recentlyActive,
selectExecutor,
getExecutorData,
getExecutorEvents,
};
}
@@ -8,6 +8,7 @@ import type {
AgentSource,
Conversation,
HealthResponse,
MetaResponse,
RunAgentRequest,
RunWorkflowRequest,
WorkflowInfo,
@@ -32,6 +33,9 @@ interface BackendEntityInfo {
tools?: (string | Record<string, unknown>)[];
metadata: Record<string, unknown>;
source?: string;
// Deployment support
deployment_supported?: boolean;
deployment_reason?: string;
// Agent-specific fields (present when type === "agent")
instructions?: string;
model?: string;
@@ -64,8 +68,8 @@ const DEFAULT_API_BASE_URL =
: ""; // Default to relative URLs (same host as frontend)
// Retry configuration for streaming
const RETRY_INTERVAL_MS = 1000; // Retry every second
const MAX_RETRY_ATTEMPTS = 600; // Max 600 retries (10 minutes total)
const RETRY_INTERVAL_MS = 1000; // Base retry interval (will use exponential backoff)
const MAX_RETRY_ATTEMPTS = 10; // Max 10 retries (~30 seconds with exponential backoff)
// Get backend URL from localStorage or default
function getBackendUrl(): string {
@@ -82,9 +86,12 @@ function sleep(ms: number): Promise<void> {
class ApiClient {
private baseUrl: string;
private authToken: string | null = null;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl || getBackendUrl();
// Load auth token from localStorage on initialization
this.authToken = localStorage.getItem("devui_auth_token");
}
// Allow updating the base URL at runtime
@@ -96,27 +103,68 @@ class ApiClient {
return this.baseUrl;
}
// Set auth token and persist to localStorage
setAuthToken(token: string | null): void {
this.authToken = token;
if (token) {
localStorage.setItem("devui_auth_token", token);
} else {
localStorage.removeItem("devui_auth_token");
}
}
// Get current auth token
getAuthToken(): string | null {
return this.authToken;
}
// Clear auth token
clearAuthToken(): void {
this.setAuthToken(null);
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
// Build headers with auth token if available
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...options.headers,
},
...options,
headers,
});
if (!response.ok) {
// Handle 401 Unauthorized - clear invalid token
if (response.status === 401) {
this.clearAuthToken();
throw new Error("UNAUTHORIZED");
}
// Try to extract error message from response body
let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
// Handle detail as string or object
if (errorData.detail) {
errorMessage = errorData.detail;
if (typeof errorData.detail === "string") {
errorMessage = errorData.detail;
} else if (typeof errorData.detail === "object" && errorData.detail.error?.message) {
// Backend returns detail: { error: { message: "...", type: "...", code: "..." } }
errorMessage = errorData.detail.error.message;
}
} else if (errorData.error?.message) {
errorMessage = errorData.error.message;
}
} catch {
// If parsing fails, use default message
@@ -132,6 +180,11 @@ class ApiClient {
return this.request<HealthResponse>("/health");
}
// Server metadata
async getMeta(): Promise<MetaResponse> {
return this.request<MetaResponse>("/meta");
}
// Entity discovery using new unified endpoint
async getEntities(): Promise<{
entities: (AgentInfo | WorkflowInfo)[];
@@ -140,17 +193,14 @@ class ApiClient {
}> {
const response = await this.request<DiscoveryResponse>("/v1/entities");
// Separate agents and workflows
const agents: AgentInfo[] = [];
const workflows: WorkflowInfo[] = [];
response.entities.forEach((entity) => {
// Transform entities while preserving backend order
const entities: (AgentInfo | WorkflowInfo)[] = response.entities.map((entity) => {
if (entity.type === "agent") {
agents.push({
return {
id: entity.id,
name: entity.name,
description: entity.description,
type: "agent",
type: "agent" as const,
source: (entity.source as AgentSource) || "directory",
tools: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
@@ -161,22 +211,26 @@ class ApiClient {
? entity.metadata.module_path
: undefined,
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
// Deployment support
deployment_supported: entity.deployment_supported,
deployment_reason: entity.deployment_reason,
// Agent-specific fields
instructions: entity.instructions,
model: entity.model,
chat_client_type: entity.chat_client_type,
context_providers: entity.context_providers,
middleware: entity.middleware,
});
} else if (entity.type === "workflow") {
};
} else {
// Workflow
const firstTool = entity.tools?.[0];
const startExecutorId = typeof firstTool === "string" ? firstTool : "";
workflows.push({
return {
id: entity.id,
name: entity.name,
description: entity.description,
type: "workflow",
type: "workflow" as const,
source: (entity.source as AgentSource) || "directory",
executors: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
@@ -187,17 +241,24 @@ class ApiClient {
? entity.metadata.module_path
: undefined,
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
// Deployment support
deployment_supported: entity.deployment_supported,
deployment_reason: entity.deployment_reason,
input_schema:
(entity.input_schema as unknown as import("@/types").JSONSchema) || {
type: "string",
}, // Default schema
input_type_name: entity.input_type_name || "Input",
start_executor_id: startExecutorId,
});
};
}
});
return { entities: [...agents, ...workflows], agents, workflows };
// Create filtered arrays for backward compatibility
const agents = entities.filter((e): e is AgentInfo => e.type === "agent");
const workflows = entities.filter((e): e is WorkflowInfo => e.type === "workflow");
return { entities, agents, workflows };
}
// Legacy methods for compatibility
@@ -213,7 +274,7 @@ class ApiClient {
async getAgentInfo(agentId: string): Promise<AgentInfo> {
// Get detailed entity info from unified endpoint
return this.request<AgentInfo>(`/v1/entities/${agentId}/info`);
return this.request<AgentInfo>(`/v1/entities/${agentId}/info?type=agent`);
}
async getWorkflowInfo(
@@ -221,7 +282,17 @@ class ApiClient {
): Promise<import("@/types").WorkflowInfo> {
// Get detailed entity info from unified endpoint
return this.request<import("@/types").WorkflowInfo>(
`/v1/entities/${workflowId}/info`
`/v1/entities/${workflowId}/info?type=workflow`
);
}
async reloadEntity(entityId: string): Promise<{ success: boolean; message: string }> {
// Hot reload entity - clears cache and forces reimport on next access
return this.request<{ success: boolean; message: string }>(
`/v1/entities/${entityId}/reload`,
{
method: "POST",
}
);
}
@@ -232,10 +303,23 @@ class ApiClient {
async createConversation(
metadata?: Record<string, string>
): Promise<Conversation> {
// Check if OAI proxy mode is enabled
const { oaiMode } = await import("@/stores").then((m) => ({
oaiMode: m.useDevUIStore.getState().oaiMode,
}));
const headers: Record<string, string> = {};
// Add proxy mode header if enabled
if (oaiMode.enabled) {
headers["X-Proxy-Backend"] = "openai";
}
const response = await this.request<ConversationApiResponse>(
"/v1/conversations",
{
method: "POST",
headers,
body: JSON.stringify({ metadata }),
}
);
@@ -315,6 +399,19 @@ class ApiClient {
return this.request<{ data: unknown[]; has_more: boolean }>(url);
}
async deleteConversationItem(
conversationId: string,
itemId: string
): Promise<void> {
const response = await fetch(
`${this.baseUrl}/v1/conversations/${conversationId}/items/${itemId}`,
{ method: "DELETE" }
);
if (!response.ok) {
throw new Error(`Failed to delete item: ${response.statusText}`);
}
}
// OpenAI-compatible streaming methods using /v1/responses endpoint
// Private helper method that handles the actual streaming with retry logic
@@ -323,6 +420,35 @@ class ApiClient {
conversationId?: string,
resumeResponseId?: string
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Check if OpenAI proxy mode is enabled
const { oaiMode } = await import("@/stores").then((m) => ({
oaiMode: m.useDevUIStore.getState().oaiMode,
}));
// Modify request if OAI mode is enabled
if (oaiMode.enabled) {
// Override model with OAI model
openAIRequest.model = oaiMode.model;
// Merge optional OpenAI parameters
if (oaiMode.temperature !== undefined) {
openAIRequest.temperature = oaiMode.temperature;
}
if (oaiMode.max_output_tokens !== undefined) {
openAIRequest.max_output_tokens = oaiMode.max_output_tokens;
}
if (oaiMode.top_p !== undefined) {
openAIRequest.top_p = oaiMode.top_p;
}
if (oaiMode.instructions !== undefined) {
openAIRequest.instructions = oaiMode.instructions;
}
// Reasoning parameters (for o-series models)
if (oaiMode.reasoning_effort !== undefined) {
openAIRequest.reasoning = { effort: oaiMode.reasoning_effort };
}
}
let lastSequenceNumber = -1;
let retryCount = 0;
let hasYieldedAnyEvent = false;
@@ -367,26 +493,68 @@ class ApiClient {
params.set("starting_after", lastSequenceNumber.toString());
}
const url = `${this.baseUrl}/v1/responses/${currentResponseId}?${params.toString()}`;
const headers: Record<string, string> = {
Accept: "text/event-stream",
};
// Add auth token if available
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
response = await fetch(url, {
method: "GET",
headers: {
Accept: "text/event-stream",
},
headers,
});
} else {
const url = `${this.baseUrl}/v1/responses`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "text/event-stream",
};
// Add proxy header if OAI mode is enabled
if (oaiMode.enabled) {
headers["X-Proxy-Backend"] = "openai";
}
// Add auth token if available
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
headers,
body: JSON.stringify(openAIRequest),
});
}
if (!response.ok) {
// Try to extract detailed error message from response body
// Handle authentication errors - don't retry these
if (response.status === 401) {
this.clearAuthToken(); // Clear invalid token
throw new Error("UNAUTHORIZED"); // Special error that won't be retried
}
// Handle other client errors (400-499) - don't retry these either
if (response.status >= 400 && response.status < 500) {
let errorMessage = `Client error ${response.status}`;
try {
const errorBody = await response.json();
if (errorBody.error && errorBody.error.message) {
errorMessage = errorBody.error.message;
} else if (errorBody.detail) {
errorMessage = errorBody.detail;
}
} catch {
// Fallback to generic message
}
throw new Error(`CLIENT_ERROR: ${errorMessage}`);
}
// Server errors (500-599) - these can be retried
let errorMessage = `Request failed with status ${response.status}`;
try {
const errorBody = await response.json();
@@ -519,18 +687,26 @@ class ApiClient {
reader.releaseLock();
}
} catch (error) {
// Network error occurred - prepare to retry
const errorMessage = error instanceof Error ? error.message : String(error);
// Don't retry on auth errors or client errors
if (errorMessage === "UNAUTHORIZED" || errorMessage.startsWith("CLIENT_ERROR:")) {
throw error; // Re-throw without retrying
}
// Network error or server error occurred - prepare to retry
retryCount++;
if (retryCount > MAX_RETRY_ATTEMPTS) {
// Max retries exceeded - give up
throw new Error(
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${error instanceof Error ? error.message : String(error)}`
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${errorMessage}`
);
}
// Wait before retrying
await sleep(RETRY_INTERVAL_MS);
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
const retryDelay = Math.min(RETRY_INTERVAL_MS * Math.pow(2, retryCount - 1), 30000);
await sleep(retryDelay);
// Loop will retry with GET if we have response_id, otherwise POST
}
}
@@ -543,7 +719,7 @@ class ApiClient {
resumeResponseId?: string
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
const openAIRequest: AgentFrameworkRequest = {
model: agentId, // Model IS the entity_id (simplified routing!)
metadata: { entity_id: agentId }, // Entity ID in metadata for routing
input: request.input, // Direct OpenAI ResponseInputParam
stream: true,
conversation: request.conversation_id, // OpenAI standard conversation param
@@ -559,6 +735,7 @@ class ApiClient {
conversationId?: string,
resumeResponseId?: string
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Proxy mode handling is now inside streamOpenAIResponse
yield* this.streamOpenAIResponse(openAIRequest, conversationId, resumeResponseId);
}
@@ -567,12 +744,15 @@ class ApiClient {
workflowId: string,
request: RunWorkflowRequest
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Convert to OpenAI format - use model field for entity_id (same as agents)
// Convert to OpenAI format - use metadata.entity_id for routing
const openAIRequest: AgentFrameworkRequest = {
model: workflowId, // Use workflow ID in model field (matches agent pattern)
input: request.input_data || "", // Send dict directly, no stringification needed
metadata: { entity_id: workflowId }, // Entity ID in metadata for routing
input: JSON.stringify(request.input_data || {}), // Serialize workflow input as JSON string
stream: true,
conversation: request.conversation_id, // Include conversation if present
extra_body: request.checkpoint_id
? { entity_id: workflowId, checkpoint_id: request.checkpoint_id }
: undefined, // Pass checkpoint_id if provided
};
yield* this.streamOpenAIResponse(openAIRequest, request.conversation_id);
@@ -613,6 +793,139 @@ class ApiClient {
clearStreamingState(conversationId: string): void {
clearStreamingState(conversationId);
}
// Deployment methods
async* streamDeployment(config: {
entity_id: string;
resource_group: string;
app_name: string;
region?: string;
ui_mode?: string;
}): AsyncGenerator<{
type: string;
message: string;
url?: string;
auth_token?: string;
}> {
const response = await fetch(`${this.baseUrl}/v1/deployments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...config, stream: true }),
});
if (!response.ok) {
throw new Error(`Deployment failed: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") return;
try {
yield JSON.parse(data);
} catch (e) {
// Emit error event for parsing failures
yield {
type: "deploy.error",
message: `Failed to parse deployment event: ${e instanceof Error ? e.message : "Unknown error"}`,
};
}
}
}
}
} catch (error) {
// Emit error event before throwing
yield {
type: "deploy.failed",
message: `Stream interrupted: ${error instanceof Error ? error.message : "Unknown error"}`,
};
throw error;
} finally {
reader.releaseLock();
}
}
// ============================================================================
// Workflow Session Management (uses /conversations API)
// ============================================================================
async listWorkflowSessions(entityId: string): Promise<{ data: import("@/types").WorkflowSession[] }> {
// Workflow sessions are conversations with entity_id and type metadata
const url = `/v1/conversations?entity_id=${encodeURIComponent(entityId)}&type=workflow_session`;
const response = await this.request<{
object: "list";
data: ConversationApiResponse[];
has_more: boolean;
}>(url);
// Transform conversations to WorkflowSession format (no checkpoint counting)
const sessions = response.data.map((conv) => ({
conversation_id: conv.id,
entity_id: conv.metadata?.entity_id || entityId,
created_at: conv.created_at,
metadata: {
name: conv.metadata?.name || `Session ${new Date(conv.created_at * 1000).toLocaleString()}`,
description: conv.metadata?.description,
type: "workflow_session" as const,
},
}));
return { data: sessions };
}
async createWorkflowSession(
entityId: string,
params?: { name?: string; description?: string }
): Promise<import("@/types").WorkflowSession> {
// Create conversation with workflow session metadata
const metadata = {
entity_id: entityId,
type: "workflow_session" as const,
name: params?.name || `Session ${new Date().toLocaleString()}`,
...(params?.description && { description: params.description }),
};
const conversation = await this.createConversation(metadata);
return {
conversation_id: conversation.id,
entity_id: entityId,
created_at: conversation.created_at,
metadata: {
name: metadata.name,
description: metadata.description,
type: "workflow_session" as const,
},
};
}
async deleteWorkflowSession(_entityId: string, conversationId: string): Promise<void> {
// Delete conversation (this also deletes all associated items/checkpoints)
const success = await this.deleteConversation(conversationId);
if (!success) {
throw new Error("Failed to delete workflow session");
}
}
// Checkpoint operations now handled through standard conversation items API
// Checkpoints are conversation items with type="checkpoint"
}
// Export singleton instance
@@ -11,6 +11,9 @@ import type {
ExtendedResponseStreamEvent,
Conversation,
PendingApproval,
OAIProxyMode,
WorkflowSession,
CheckpointInfo,
} from "@/types";
import type { ConversationItem } from "@/types/openai";
import type { AttachmentItem } from "@/components/ui/attachment-gallery";
@@ -23,6 +26,7 @@ interface DevUIState {
// Entity Management Slice
agents: AgentInfo[];
workflows: WorkflowInfo[];
entities: (AgentInfo | WorkflowInfo)[]; // Full list in backend order
selectedAgent: AgentInfo | WorkflowInfo | undefined;
isLoadingEntities: boolean;
entityError: string | null;
@@ -42,8 +46,16 @@ interface DevUIState {
};
pendingApprovals: PendingApproval[];
// Workflow Session Slice (workflow-specific session management)
currentSession: WorkflowSession | undefined;
availableSessions: WorkflowSession[];
sessionCheckpoints: CheckpointInfo[];
loadingSessions: boolean;
loadingCheckpoints: boolean;
// UI Slice
showDebugPanel: boolean;
debugPanelMinimized: boolean;
debugPanelWidth: number;
debugEvents: ExtendedResponseStreamEvent[];
isResizing: boolean;
@@ -53,6 +65,36 @@ interface DevUIState {
showGallery: boolean;
showDeployModal: boolean;
showEntityNotFoundToast: boolean;
// Toast Slice
toasts: Array<{
id: string;
message: string;
type: "info" | "success" | "warning" | "error";
duration?: number;
}>;
// OpenAI Proxy Mode Slice
oaiMode: OAIProxyMode;
// Server Meta Slice
uiMode: "developer" | "user";
runtime: "python" | "dotnet";
serverCapabilities: {
tracing: boolean;
openai_proxy: boolean;
deployment: boolean;
};
authRequired: boolean;
// Deployment Slice
isDeploying: boolean;
deploymentLogs: string[];
lastDeployment: {
url: string;
authToken: string;
} | null;
azureDeploymentEnabled: boolean; // Feature flag for Azure deployment
}
// ========================================
@@ -63,6 +105,7 @@ interface DevUIActions {
// Entity Actions
setAgents: (agents: AgentInfo[]) => void;
setWorkflows: (workflows: WorkflowInfo[]) => void;
setEntities: (entities: (AgentInfo | WorkflowInfo)[]) => void;
setSelectedAgent: (agent: AgentInfo | WorkflowInfo | undefined) => void;
addAgent: (agent: AgentInfo) => void;
addWorkflow: (workflow: WorkflowInfo) => void;
@@ -84,8 +127,18 @@ interface DevUIActions {
updateConversationUsage: (tokens: number) => void;
setPendingApprovals: (approvals: PendingApproval[]) => void;
// Workflow Session Actions
setCurrentSession: (session: WorkflowSession | undefined) => void;
setAvailableSessions: (sessions: WorkflowSession[]) => void;
setSessionCheckpoints: (checkpoints: CheckpointInfo[]) => void;
setLoadingSessions: (loading: boolean) => void;
setLoadingCheckpoints: (loading: boolean) => void;
addSession: (session: WorkflowSession) => void;
removeSession: (conversationId: string) => void;
// UI Actions
setShowDebugPanel: (show: boolean) => void;
setDebugPanelMinimized: (minimized: boolean) => void;
setDebugPanelWidth: (width: number) => void;
addDebugEvent: (event: ExtendedResponseStreamEvent) => void;
clearDebugEvents: () => void;
@@ -97,6 +150,29 @@ interface DevUIActions {
setShowDeployModal: (show: boolean) => void;
setShowEntityNotFoundToast: (show: boolean) => void;
// Toast Actions
addToast: (toast: {
message: string;
type?: "info" | "success" | "warning" | "error";
duration?: number;
}) => void;
removeToast: (id: string) => void;
// OpenAI Proxy Mode Actions
setOAIMode: (config: OAIProxyMode) => void;
toggleOAIMode: () => void;
// Server Meta Actions
setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { tracing: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean }) => void;
// Deployment Actions
startDeployment: () => void;
addDeploymentLog: (log: string) => void;
setDeploymentResult: (result: { url: string; authToken: string }) => void;
stopDeployment: () => void;
clearDeploymentState: () => void;
setAzureDeploymentEnabled: (enabled: boolean) => void;
// Combined Actions (handle multiple state updates + side effects)
selectEntity: (entity: AgentInfo | WorkflowInfo) => void;
}
@@ -118,6 +194,7 @@ export const useDevUIStore = create<DevUIStore>()(
// Entity State
agents: [],
workflows: [],
entities: [],
selectedAgent: undefined,
isLoadingEntities: true,
entityError: null,
@@ -134,8 +211,16 @@ export const useDevUIStore = create<DevUIStore>()(
conversationUsage: { total_tokens: 0, message_count: 0 },
pendingApprovals: [],
// Workflow Session State
currentSession: undefined,
availableSessions: [],
sessionCheckpoints: [],
loadingSessions: false,
loadingCheckpoints: false,
// UI State
showDebugPanel: true,
debugPanelMinimized: false,
debugPanelWidth: 320,
debugEvents: [],
isResizing: false,
@@ -146,12 +231,38 @@ export const useDevUIStore = create<DevUIStore>()(
showDeployModal: false,
showEntityNotFoundToast: false,
// Toast State
toasts: [],
// OpenAI Proxy Mode State
oaiMode: {
enabled: false,
model: "gpt-4o-mini", // Default to cheaper model
},
// Server Meta State
uiMode: "developer", // Default to developer mode
runtime: "python", // Default to Python runtime
serverCapabilities: {
tracing: false,
openai_proxy: false,
deployment: false,
},
authRequired: false,
// Deployment State
isDeploying: false,
deploymentLogs: [],
lastDeployment: null,
azureDeploymentEnabled: false, // Default to disabled for safety
// ========================================
// Entity Actions
// ========================================
setAgents: (agents) => set({ agents }),
setWorkflows: (workflows) => set({ workflows }),
setEntities: (entities) => set({ entities }),
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
addAgent: (agent) =>
set((state) => ({ agents: [...state.agents, agent] })),
@@ -216,14 +327,69 @@ export const useDevUIStore = create<DevUIStore>()(
})),
setPendingApprovals: (approvals) => set({ pendingApprovals: approvals }),
// ========================================
// Workflow Session Actions
// ========================================
setCurrentSession: (session) => set({ currentSession: session }),
setAvailableSessions: (sessions) => set({ availableSessions: sessions }),
setSessionCheckpoints: (checkpoints) =>
set({ sessionCheckpoints: checkpoints }),
setLoadingSessions: (loading) => set({ loadingSessions: loading }),
setLoadingCheckpoints: (loading) => set({ loadingCheckpoints: loading }),
addSession: (session) =>
set((state) => ({
availableSessions: [session, ...state.availableSessions],
})),
removeSession: (conversationId) =>
set((state) => ({
availableSessions: state.availableSessions.filter(
(s) => s.conversation_id !== conversationId
),
// Clear current session if it's the one being deleted
currentSession:
state.currentSession?.conversation_id === conversationId
? undefined
: state.currentSession,
// Clear checkpoints if they belong to deleted session
sessionCheckpoints:
state.currentSession?.conversation_id === conversationId
? []
: state.sessionCheckpoints,
})),
// ========================================
// UI Actions
// ========================================
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
setDebugPanelMinimized: (minimized) => set({ debugPanelMinimized: minimized }),
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
addDebugEvent: (event) =>
set((state) => ({ debugEvents: [...state.debugEvents, event] })),
set((state) => {
// Generate unique timestamp for each event
// Use current time + small increment to ensure uniqueness even for rapid events
const baseTimestamp = Math.floor(Date.now() / 1000);
const lastTimestamp = state.debugEvents.length > 0
? (state.debugEvents[state.debugEvents.length - 1] as any)._uiTimestamp || 0
: 0;
// Ensure new timestamp is always greater than the last one
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
return {
debugEvents: [
...state.debugEvents,
{
...event,
// Add UI display timestamp when event is received (Unix seconds)
// Each event gets a unique timestamp to preserve chronological order
_uiTimestamp: ('created_at' in event && event.created_at)
? event.created_at
: uniqueTimestamp,
} as ExtendedResponseStreamEvent & { _uiTimestamp: number },
],
};
}),
clearDebugEvents: () => set({ debugEvents: [] }),
setIsResizing: (resizing) => set({ isResizing: resizing }),
@@ -237,6 +403,154 @@ export const useDevUIStore = create<DevUIStore>()(
setShowEntityNotFoundToast: (show) =>
set({ showEntityNotFoundToast: show }),
// ========================================
// Toast Actions
// ========================================
addToast: (toast) =>
set((state) => ({
toasts: [
...state.toasts,
{
id: `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: toast.type || "info",
duration: toast.duration || 4000,
...toast,
},
],
})),
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
// ========================================
// OpenAI Proxy Mode Actions
// ========================================
setOAIMode: (config) =>
set((state) => {
// If enabling OAI mode, clear conversation state
if (config.enabled && !state.oaiMode.enabled) {
// Clear ALL conversation localStorage caches
Object.keys(localStorage).forEach(key => {
if (key.startsWith('devui_convs_')) {
localStorage.removeItem(key);
}
});
return {
oaiMode: config,
// Clear conversation state when switching to OAI mode
currentConversation: undefined,
availableConversations: [],
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
debugEvents: [],
};
}
// If disabling OAI mode, also clear state
if (!config.enabled && state.oaiMode.enabled) {
// Clear ALL conversation localStorage caches
Object.keys(localStorage).forEach(key => {
if (key.startsWith('devui_convs_')) {
localStorage.removeItem(key);
}
});
return {
oaiMode: config,
// Clear conversation state when switching back to local mode
currentConversation: undefined,
availableConversations: [],
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
debugEvents: [],
};
}
// Just update config (model, temperature, etc.) without clearing state
return { oaiMode: config };
}),
toggleOAIMode: () =>
set((state) => {
const newEnabled = !state.oaiMode.enabled;
return {
oaiMode: { ...state.oaiMode, enabled: newEnabled },
// Clear conversation state when toggling
currentConversation: undefined,
availableConversations: [],
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
debugEvents: [],
};
}),
// ========================================
// Server Meta Actions
// ========================================
setServerMeta: (meta) =>
set({
uiMode: meta.uiMode,
runtime: meta.runtime,
serverCapabilities: meta.capabilities,
authRequired: meta.authRequired,
}),
// ========================================
// Deployment Actions
// ========================================
startDeployment: () =>
set({
isDeploying: true,
deploymentLogs: [],
lastDeployment: null,
}),
addDeploymentLog: (log) =>
set((state) => ({
deploymentLogs: [...state.deploymentLogs, log],
})),
setDeploymentResult: (result) =>
set({
isDeploying: false,
lastDeployment: result,
}),
stopDeployment: () =>
set({
isDeploying: false,
}),
clearDeploymentState: () =>
set({
isDeploying: false,
deploymentLogs: [],
lastDeployment: null,
}),
setAzureDeploymentEnabled: (enabled) =>
set({ azureDeploymentEnabled: enabled }),
// ========================================
// Combined Actions
// ========================================
@@ -245,6 +559,7 @@ export const useDevUIStore = create<DevUIStore>()(
* Select an entity (agent/workflow) and handle all side effects:
* - Update selected entity
* - Clear conversation state (FIXES THE BUG!)
* - Clear session state (for workflows)
* - Clear debug events
* - Update URL
*/
@@ -261,6 +576,10 @@ export const useDevUIStore = create<DevUIStore>()(
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
// Clear workflow session state when switching entities
currentSession: undefined,
availableSessions: [], // Let WorkflowView reload sessions
sessionCheckpoints: [],
// Clear debug events when switching
debugEvents: [],
});
@@ -276,7 +595,10 @@ export const useDevUIStore = create<DevUIStore>()(
// Only persist UI preferences, not runtime state
partialize: (state) => ({
showDebugPanel: state.showDebugPanel,
debugPanelMinimized: state.debugPanelMinimized,
debugPanelWidth: state.debugPanelWidth,
oaiMode: state.oaiMode, // Persist OpenAI proxy mode settings
azureDeploymentEnabled: state.azureDeploymentEnabled, // Persist Azure deployment preference
}),
}
),
@@ -68,12 +68,13 @@ export type ResponseInputParam = ResponseInputItem[];
// Agent Framework extension fields (matches backend AgentFrameworkExtraBody)
export interface AgentFrameworkExtraBody {
entity_id: string;
checkpoint_id?: string; // Optional checkpoint ID for workflow resume
// input_data removed - now using standard input field for all data
}
// Agent Framework Request - OpenAI ResponseCreateParams with extensions
export interface AgentFrameworkRequest {
model: string;
model?: string;
input: string | ResponseInputParam | Record<string, unknown>; // Union type matching OpenAI + dict for workflows
stream?: boolean;
@@ -85,8 +86,15 @@ export interface AgentFrameworkRequest {
metadata?: Record<string, unknown>;
temperature?: number;
max_output_tokens?: number;
top_p?: number;
tools?: Record<string, unknown>[];
// Reasoning parameters (for o-series models)
reasoning?: {
effort?: "minimal" | "low" | "medium" | "high";
summary?: "auto" | "concise" | "detailed";
};
// Agent Framework extension - strongly typed
extra_body?: AgentFrameworkExtraBody;
entity_id?: string; // Allow entity_id as top-level field too
@@ -32,6 +32,9 @@ export interface AgentInfo {
module_path?: string;
required_env_vars?: EnvVarRequirement[];
metadata?: Record<string, unknown>; // Backend metadata including lazy_loaded flag
// Deployment support
deployment_supported?: boolean;
deployment_reason?: string;
// Agent-specific fields
instructions?: string;
model?: string;
@@ -71,6 +74,7 @@ export interface WorkflowInfo extends Omit<AgentInfo, "tools"> {
input_schema: JSONSchema; // JSON Schema for workflow input
input_type_name: string; // Human-readable input type name
start_executor_id: string; // Entry point executor ID
// Note: DevUI provides runtime checkpoint storage for ALL workflows via conversations
}
// OpenAI Conversations API (standard)
@@ -89,6 +93,22 @@ export interface RunAgentRequest {
export interface RunWorkflowRequest {
input_data: Record<string, unknown>;
conversation_id?: string;
checkpoint_id?: string;
}
// OpenAI Proxy Mode Configuration
export interface OAIProxyMode {
enabled: boolean;
model: string; // Model ID like "gpt-4o", "gpt-4o-mini", or custom
// Optional OpenAI Responses API parameters
temperature?: number;
max_output_tokens?: number;
top_p?: number;
instructions?: string;
// Reasoning parameters (for o-series models)
reasoning_effort?: "minimal" | "low" | "medium" | "high";
}
// Legacy types - DEPRECATED - use new structured events from openai.ts instead
@@ -133,6 +153,19 @@ export interface HealthResponse {
version: string;
}
export interface MetaResponse {
ui_mode: "developer" | "user";
version: string;
framework: string;
runtime: "python" | "dotnet";
capabilities: {
tracing: boolean;
openai_proxy: boolean;
deployment: boolean;
};
auth_required: boolean;
}
// Chat message types matching Agent Framework
export interface ChatMessage {
id: string;
@@ -175,3 +208,54 @@ export interface PendingApproval {
arguments: Record<string, unknown>;
};
}
// Deployment types
export interface DeploymentConfig {
entity_id: string;
resource_group: string;
app_name: string;
region?: string;
ui_mode?: string;
ui_enabled?: boolean;
stream?: boolean;
}
export interface DeploymentEvent {
type: string;
message: string;
url?: string;
auth_token?: string;
}
export interface Deployment {
id: string;
entity_id: string;
resource_group: string;
app_name: string;
region: string;
url: string;
status: string;
created_at: string;
error?: string;
}
// Workflow Session Management Types
export interface WorkflowSession {
conversation_id: string;
entity_id: string;
created_at: number;
metadata: {
name?: string;
description?: string;
type: "workflow_session";
[key: string]: unknown;
};
}
export interface CheckpointInfo {
checkpoint_id: string;
workflow_id: string;
timestamp: number;
iteration_count: number;
metadata?: Record<string, unknown>;
}
@@ -65,7 +65,7 @@ export interface ResponseFailedEvent {
// Custom Agent Framework OpenAI event types with structured data
export interface ResponseWorkflowEventComplete {
type: "response.workflow_event.complete";
type: "response.workflow_event.completed";
data: {
event_type: string;
data?: Record<string, unknown>;
@@ -125,6 +125,32 @@ export interface ResponseFunctionToolCall {
status?: "in_progress" | "completed" | "incomplete";
}
// DevUI Extension: Output item types for response.output_item.added events
export interface ResponseOutputImageItem {
id: string;
type: "output_image";
image_url: string;
alt_text?: string;
mime_type: string;
}
export interface ResponseOutputFileItem {
id: string;
type: "output_file";
filename: string;
file_url?: string;
file_data?: string;
mime_type: string;
}
export interface ResponseOutputDataItem {
id: string;
type: "output_data";
data: string;
mime_type: string;
description?: string;
}
// Workflow Item Types - flexible interface for any workflow item
export interface WorkflowItem {
type: string; // "executor_action", "workflow_action", "message", or any future type
@@ -147,24 +173,34 @@ export function isExecutorAction(item: WorkflowItem): item is ExecutorActionItem
return item.type === "executor_action" && "executor_id" in item;
}
// OpenAI Responses API - Output Item Events
// Union of all possible output items
export type ResponseOutputItem =
| ResponseFunctionToolCall
| ResponseOutputImageItem
| ResponseOutputFileItem
| ResponseOutputDataItem
| ExecutorActionItem
| WorkflowItem;
// OpenAI Responses API - Output Item Added Event
// OpenAI standard: Output item added event (extended to support our output types)
export interface ResponseOutputItemAddedEvent {
type: "response.output_item.added";
item: WorkflowItem | ResponseFunctionToolCall | any; // Flexible to support various item types
item: ResponseOutputItem;
output_index: number;
sequence_number?: number;
}
export interface ResponseOutputItemDoneEvent {
type: "response.output_item.done";
item: WorkflowItem | ResponseFunctionToolCall | any;
item: ResponseOutputItem;
output_index: number;
sequence_number?: number;
}
// Trace event - matching actual backend output
export interface ResponseTraceEventComplete {
type: "response.trace_event.complete";
type: "response.trace.completed";
data: {
operation_name?: string;
duration_ms?: number;
@@ -179,7 +215,7 @@ export interface ResponseTraceEventComplete {
// New trace event format from backend
export interface ResponseTraceComplete {
type: "response.trace.complete";
type: "response.trace.completed";
data: {
type?: string;
span_id?: string;
@@ -244,6 +280,20 @@ export interface ResponseFunctionResultComplete {
timestamp?: string; // Optional ISO timestamp for UI display
}
// DevUI Extension: Workflow Requests Human Input (HIL)
export interface ResponseRequestInfoEvent {
type: "response.request_info.requested";
request_id: string;
source_executor_id: string;
request_type: string;
request_data: Record<string, unknown>;
request_schema: Record<string, unknown>;
item_id: string;
output_index: number;
sequence_number: number;
timestamp: string;
}
// DevUI Extension: Turn Separator (UI-only event for grouping)
export interface TurnSeparatorEvent {
type: "debug.turn_separator";
@@ -266,6 +316,7 @@ export type StructuredEvent =
| ResponseFunctionCallDelta
| ResponseFunctionCallArgumentsDelta
| ResponseFunctionResultComplete
| ResponseRequestInfoEvent
| ResponseErrorEvent
| ResponseFunctionApprovalRequestedEvent
| ResponseFunctionApprovalRespondedEvent
@@ -374,6 +425,18 @@ export interface MessageInputFile {
filename?: string;
}
// DevUI Extension: Function approval request content (shown in chat)
export interface MessageFunctionApprovalRequestContent {
type: "function_approval_request";
request_id: string;
status: "pending" | "approved" | "rejected";
function_call: {
id: string;
name: string;
arguments: Record<string, unknown>;
};
}
// DevUI Extension: Function approval response content
export interface MessageFunctionApprovalResponseContent {
type: "function_approval_response";
@@ -386,12 +449,45 @@ export interface MessageFunctionApprovalResponseContent {
};
}
// ============================================================================
// DevUI Extension: Output Content Types (Agent-Generated Media/Data)
// ============================================================================
// These extend the OpenAI Responses API to support rich content outputs
// that aren't natively supported (images, files, data). They mirror the
// input types but for agent outputs.
export interface MessageOutputImage {
type: "output_image";
image_url: string; // URL or data URI (data:image/png;base64,...)
alt_text?: string;
mime_type: string;
}
export interface MessageOutputFile {
type: "output_file";
filename: string;
file_url?: string;
file_data?: string; // base64
mime_type: string;
}
export interface MessageOutputData {
type: "output_data";
data: string;
mime_type: string;
description?: string;
}
export type MessageContent =
| MessageTextContent
| MessageInputTextContent
| MessageOutputTextContent
| MessageInputImage
| MessageInputFile
| MessageOutputImage
| MessageOutputFile
| MessageOutputData
| MessageFunctionApprovalRequestContent
| MessageFunctionApprovalResponseContent;
// Message item (user/assistant messages with content)
@@ -401,6 +497,7 @@ export interface ConversationMessage {
role: "user" | "assistant" | "system" | "tool";
content: MessageContent[];
status: "in_progress" | "completed" | "incomplete";
created_at?: number; // Unix timestamp in seconds - when this message was created
usage?: {
input_tokens: number;
output_tokens: number;
@@ -416,6 +513,7 @@ export interface ConversationFunctionCall {
name: string;
arguments: string;
status: "in_progress" | "completed" | "incomplete";
created_at?: number; // Unix timestamp in seconds - when this function call was made
}
// Function call output item
@@ -425,6 +523,7 @@ export interface ConversationFunctionCallOutput {
call_id: string;
output: string;
status?: "in_progress" | "completed" | "incomplete";
created_at?: number; // Unix timestamp in seconds - when this function result was received
}
// Union of all conversation item types
@@ -11,6 +11,23 @@ import type {
import type { Workflow } from "@/types/workflow";
import { getTypedWorkflow } from "@/types/workflow";
/**
* Truncates text that exceeds the maximum length and appends ellipsis
* @param text - The text to truncate
* @param maxLength - Maximum length before truncation (default: 50)
* @param ellipsis - String to append when truncated (default: '...')
* @returns Truncated text with ellipsis if it exceeds maxLength, otherwise original text
*
* @example
* truncateText('Hello World', 5) // 'Hello...'
* truncateText('Short', 10) // 'Short'
* truncateText('workflow_assistant_43ca50a006aa425e96e8fcf54206a7e3', 35) // 'workflow_assistant_43ca50a006aa4...'
*/
export function truncateText(text: string, maxLength: number = 50, ellipsis: string = '...'): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + ellipsis;
}
export interface WorkflowDumpExecutor {
id: string;
type: string;
@@ -164,6 +181,8 @@ export function convertWorkflowDumpToEdges(
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
sourceHandle: "source",
targetHandle: "target",
type: "default",
animated: false,
style: {
@@ -307,7 +326,7 @@ export function applyDagreLayout(
/**
* Process workflow events and extract node updates
* Handles both new standard OpenAI events and legacy workflow events
* Handles both standard OpenAI events and fallback workflow_event format
*/
export function processWorkflowEvents(
events: ExtendedResponseStreamEvent[],
@@ -316,12 +335,29 @@ export function processWorkflowEvents(
const nodeUpdates: Record<string, NodeUpdate> = {};
let hasWorkflowStarted = false;
// Track the latest item ID for each executor to handle multiple runs
const latestItemIds: Record<string, string> = {};
events.forEach((event) => {
// Handle new standard OpenAI events
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
const item = (event as any).item;
if (item && item.type === "executor_action" && item.executor_id) {
const executorId = item.executor_id;
const itemId = item.id;
// Track the latest item ID for this executor
if (event.type === "response.output_item.added") {
latestItemIds[executorId] = itemId;
}
// Only process this event if it's for the latest item ID of this executor
// This prevents older "done" events from overwriting newer "added" events
const isLatestItem = latestItemIds[executorId] === itemId;
if (!isLatestItem && event.type === "response.output_item.done") {
return; // Skip this old completion event
}
let state: ExecutorState = "pending";
let error: string | undefined;
@@ -352,9 +388,9 @@ export function processWorkflowEvents(
else if (event.type === "response.created" || event.type === "response.in_progress") {
hasWorkflowStarted = true;
}
// Legacy support for older backends
// Handle workflow event format
else if (
event.type === "response.workflow_event.complete" &&
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
@@ -400,16 +436,38 @@ export function processWorkflowEvents(
}
});
// If workflow has started and we have a start executor, set it to running
// (unless it already has a specific state from an ExecutorInvokedEvent)
// FALLBACK LOGIC: If workflow has started and we have a start executor, set it to running
// ONLY if it hasn't received any explicit executor events
// This prevents overwriting the actual state after the executor has run
if (hasWorkflowStarted && startExecutorId && !nodeUpdates[startExecutorId]) {
nodeUpdates[startExecutorId] = {
nodeId: startExecutorId,
state: "running",
data: undefined,
error: undefined,
timestamp: new Date().toISOString(),
};
// Additional check: only set to running if we don't have completion/failure events for this executor
// This prevents setting to "running" after the executor has already completed
const hasCompletionEvent = events.some((event) => {
if (event.type === "response.output_item.done") {
const item = (event as any).item;
return item && item.type === "executor_action" && item.executor_id === startExecutorId;
}
if (event.type === "response.workflow_event.completed" && "data" in event && event.data) {
const data = event.data as any;
return data.executor_id === startExecutorId &&
(data.event_type === "ExecutorCompletedEvent" ||
data.event_type === "ExecutorFailedEvent" ||
data.event_type?.includes("Error") ||
data.event_type?.includes("Failed"));
}
return false;
});
// Only set to running if the executor hasn't completed yet
if (!hasCompletionEvent) {
nodeUpdates[startExecutorId] = {
nodeId: startExecutorId,
state: "running",
data: undefined,
error: undefined,
timestamp: new Date().toISOString(),
};
}
}
return nodeUpdates;
@@ -466,9 +524,9 @@ export function getCurrentlyExecutingExecutors(
};
}
}
// Legacy support for older backends
// Handle workflow event format
else if (
event.type === "response.workflow_event.complete" &&
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
@@ -515,7 +573,7 @@ export function updateEdgesWithSequenceAnalysis(
events.forEach((event) => {
if (
event.type === "response.workflow_event.complete" &&
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
@@ -584,3 +642,67 @@ export function updateEdgesWithSequenceAnalysis(
};
});
}
/**
* Consolidate bidirectional edges into single edges with arrows on both ends
* This reduces visual clutter when edges go in both directions between nodes
*
* Smart handle selection algorithm:
* The current implementation keeps whichever edge was encountered first in the array.
* Since edges are typically created in workflow definition order (following the primary flow),
* this naturally keeps the "forward" edge and discards the "backward" one.
*
* For example, if the workflow defines:
* 1. coordinator planner (primary flow)
* 2. planner coordinator (feedback loop)
*
* We keep edge #1 and add bidirectional arrows. This ensures the edge follows
* the natural outputinput handle connection of the primary flow direction.
*
* React Flow will automatically route the edge to avoid overlaps, and the
* bidirectional arrows indicate that communication flows both ways.
*/
export function consolidateBidirectionalEdges(edges: Edge[]): Edge[] {
const edgeMap = new Map<string, Edge>();
const bidirectionalKeys = new Set<string>();
edges.forEach(edge => {
const forwardKey = `${edge.source}-${edge.target}`;
const reverseKey = `${edge.target}-${edge.source}`;
// Check if we already have the reverse edge
if (edgeMap.has(reverseKey)) {
// Mark both keys as bidirectional
bidirectionalKeys.add(reverseKey);
bidirectionalKeys.add(forwardKey);
// Update the existing reverse edge to be bidirectional
const existingEdge = edgeMap.get(reverseKey)!;
// Keep the existing edge's handles (they follow the primary workflow direction)
// Add bidirectional arrows to show two-way communication
edgeMap.set(reverseKey, {
...existingEdge,
markerStart: {
type: 'arrow' as const,
width: 20,
height: 20,
},
markerEnd: {
type: 'arrow' as const,
width: 20,
height: 20,
},
data: {
...existingEdge.data,
isBidirectional: true,
},
});
} else if (!bidirectionalKeys.has(forwardKey)) {
// Only add if this isn't the reverse of a bidirectional pair
edgeMap.set(forwardKey, edge);
}
});
return Array.from(edgeMap.values());
}
+21 -1
View File
@@ -584,13 +584,33 @@
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-slot@^1.2.3", "@radix-ui/react-slot@1.2.3":
"@radix-ui/react-separator@^1.1.7":
version "1.1.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470"
integrity sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==
dependencies:
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot@1.2.3", "@radix-ui/react-slot@^1.2.3":
version "1.2.3"
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz"
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-switch@^1.2.6":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-use-size" "1.1.1"
"@radix-ui/react-tabs@^1.1.13":
version "1.1.13"
resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251105"
version = "1.0.0b251111"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -89,7 +89,7 @@ def capture_agent_stream_with_tracing(client: OpenAI, agent_id: str, scenario: s
try:
stream = client.responses.create(
model=agent_id, # DevUI uses model field as entity_id
metadata={"entity_id": agent_id},
input="Tell me about the weather in Tokyo. I want details.",
stream=True,
)
@@ -130,7 +130,7 @@ def capture_workflow_stream_with_tracing(
try:
stream = client.responses.create(
model=workflow_id, # DevUI uses model field as entity_id
metadata={"entity_id": workflow_id},
input=(
"Process this spam detection workflow with multiple emails: "
"'Buy now!', 'Hello mom', 'URGENT: Click here!'"
@@ -0,0 +1,443 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for checkpoint-as-conversation-items implementation."""
from dataclasses import dataclass
import pytest
from agent_framework import (
Executor,
InMemoryCheckpointStorage,
WorkflowBuilder,
WorkflowContext,
handler,
response_handler,
)
from agent_framework_devui._conversations import (
CheckpointConversationManager,
InMemoryConversationStore,
)
@dataclass
class WorkflowTestData:
"""Simple test data."""
value: str
@dataclass
class WorkflowHILRequest:
"""HIL request for testing."""
question: str
class WorkflowTestExecutor(Executor):
"""Test executor with HIL."""
@handler
async def process(self, data: WorkflowTestData, ctx: WorkflowContext) -> None:
"""Process data and request approval."""
await ctx.set_executor_state({"data_value": data.value})
# Request HIL (checkpoint created here)
await ctx.request_info(request_data=WorkflowHILRequest(question=f"Approve {data.value}?"), response_type=str)
@response_handler
async def handle_response(
self, original_request: WorkflowHILRequest, response: str, ctx: WorkflowContext[str]
) -> None:
"""Handle HIL response."""
state = await ctx.get_executor_state() or {}
value = state.get("data_value", "")
await ctx.send_message(f"{value}_approved" if response.lower() == "yes" else f"{value}_rejected")
@pytest.fixture
def conversation_store():
"""Create in-memory conversation store."""
return InMemoryConversationStore()
@pytest.fixture
def checkpoint_manager(conversation_store):
"""Create checkpoint manager."""
return CheckpointConversationManager(conversation_store)
@pytest.fixture
def test_workflow():
"""Create test workflow with checkpointing."""
executor = WorkflowTestExecutor(id="test_executor")
checkpoint_storage = InMemoryCheckpointStorage()
return (
WorkflowBuilder(name="Test Workflow", description="Test checkpoint behavior")
.set_start_executor(executor)
.with_checkpointing(checkpoint_storage)
.build()
)
class TestCheckpointConversationManager:
"""Test CheckpointConversationManager functionality - CONVERSATION-SCOPED."""
@pytest.mark.asyncio
async def test_conversation_scoped_checkpoint_save(self, checkpoint_manager, test_workflow):
"""Test checkpoint save in a specific conversation."""
entity_id = "test_entity"
conversation_id = f"conv_{entity_id}_test123"
# Create conversation first
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Create test checkpoint
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, shared_state={"test": "data"}
)
# Get checkpoint storage for this conversation and save
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
checkpoint_id = await storage.save_checkpoint(checkpoint)
assert checkpoint_id == checkpoint.checkpoint_id
# Verify checkpoint stored in THIS conversation only
checkpoints = await storage.list_checkpoints()
assert len(checkpoints) == 1
assert checkpoints[0].checkpoint_id == checkpoint.checkpoint_id
@pytest.mark.asyncio
async def test_conversation_isolation(self, checkpoint_manager, test_workflow):
"""Test that conversations are isolated - checkpoints don't leak between conversations."""
entity_id = "test_entity"
conv_a = f"conv_{entity_id}_aaa"
conv_b = f"conv_{entity_id}_bbb"
# Create two conversations
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conv_a
)
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conv_b
)
# Save checkpoint to conversation A
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint_a = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
messages={},
shared_state={"conversation": "A"},
)
storage_a = checkpoint_manager.get_checkpoint_storage(conv_a)
await storage_a.save_checkpoint(checkpoint_a)
# Verify conversation A has checkpoint
checkpoints_a = await storage_a.list_checkpoints()
assert len(checkpoints_a) == 1
# Verify conversation B has NO checkpoints (isolation)
storage_b = checkpoint_manager.get_checkpoint_storage(conv_b)
checkpoints_b = await storage_b.list_checkpoints()
assert len(checkpoints_b) == 0
@pytest.mark.asyncio
async def test_list_checkpoints_in_session(self, checkpoint_manager, test_workflow):
"""Test listing checkpoints within a session."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_test456"
# Create session
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Save multiple checkpoints
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
checkpoint_ids = []
for i in range(3):
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
messages={},
shared_state={"iteration": i},
)
saved_id = await storage.save_checkpoint(checkpoint)
checkpoint_ids.append(saved_id)
# List checkpoints using the storage
checkpoints_list = await storage.list_checkpoints()
assert len(checkpoints_list) == 3
# Verify all checkpoint IDs are present
loaded_ids = [cp.checkpoint_id for cp in checkpoints_list]
for saved_id in checkpoint_ids:
assert saved_id in loaded_ids
@pytest.mark.asyncio
async def test_checkpoints_appear_as_conversation_items(self, checkpoint_manager, test_workflow):
"""Test that checkpoints appear as conversation items through the standard API."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_items_test"
# Create session
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Save multiple checkpoints
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
checkpoint_ids = []
for i in range(2):
checkpoint = WorkflowCheckpoint(
checkpoint_id=f"checkpoint_{i}",
workflow_id=test_workflow.id,
messages={},
shared_state={"iteration": i},
)
saved_id = await storage.save_checkpoint(checkpoint)
checkpoint_ids.append(saved_id)
# List conversation items - should include checkpoints
items, has_more = await checkpoint_manager.conversation_store.list_items(conversation_id)
# Filter for checkpoint items
checkpoint_items = [item for item in items if (isinstance(item, dict) and item.get("type") == "checkpoint")]
# Verify we have the correct number of checkpoint items
assert len(checkpoint_items) == 2, f"Expected 2 checkpoint items, got {len(checkpoint_items)}"
# Verify checkpoint items have correct structure
for item in checkpoint_items:
assert item.get("type") == "checkpoint"
assert item.get("checkpoint_id") in checkpoint_ids
assert item.get("workflow_id") == test_workflow.id
assert "timestamp" in item
assert item.get("id").startswith("checkpoint_") # ID format: checkpoint_{checkpoint_id}
@pytest.mark.asyncio
async def test_load_checkpoint_from_session(self, checkpoint_manager, test_workflow):
"""Test loading checkpoint from a specific session."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_test789"
# Create session
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Create and save a checkpoint
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
original_checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
messages={},
shared_state={"test_key": "test_value"},
)
# Save to this session
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
await storage.save_checkpoint(original_checkpoint)
# Load checkpoint from this session
loaded_checkpoint = await storage.load_checkpoint(original_checkpoint.checkpoint_id)
assert loaded_checkpoint is not None
assert loaded_checkpoint.checkpoint_id == original_checkpoint.checkpoint_id
assert loaded_checkpoint.workflow_id == original_checkpoint.workflow_id
assert loaded_checkpoint.shared_state == {"test_key": "test_value"}
class TestCheckpointStorage:
"""Test InMemoryCheckpointStorage per conversation - SESSION-SCOPED."""
@pytest.mark.asyncio
async def test_checkpoint_storage_protocol(self, checkpoint_manager, test_workflow):
"""Test that adapter implements CheckpointStorage protocol."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_adapter_test"
# Create session
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Get storage adapter for this session
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
# Create test checkpoint
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, shared_state={"test": "data"}
)
# Test save_checkpoint
checkpoint_id = await storage.save_checkpoint(checkpoint)
assert checkpoint_id == checkpoint.checkpoint_id
# Test load_checkpoint
loaded = await storage.load_checkpoint(checkpoint_id)
assert loaded is not None
assert loaded.checkpoint_id == checkpoint_id
# Test list_checkpoint_ids
ids = await storage.list_checkpoint_ids(workflow_id=test_workflow.id)
assert checkpoint_id in ids
# Test list_checkpoints
checkpoints_list = await storage.list_checkpoints(workflow_id=test_workflow.id)
assert len(checkpoints_list) >= 1
assert any(cp.checkpoint_id == checkpoint_id for cp in checkpoints_list)
class TestIntegration:
"""Integration tests for checkpoint workflow execution."""
@pytest.mark.asyncio
async def test_manual_checkpoint_save_via_injected_storage(self, checkpoint_manager, test_workflow):
"""Test manual checkpoint save via build-time storage injection."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_integration_test1"
# Create session conversation
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Get checkpoint storage for this session
checkpoint_storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
# Set build-time storage (equivalent to .with_checkpointing() at build time)
# Note: In production, DevUI uses runtime injection via run_stream() parameter
if hasattr(test_workflow, "_runner") and hasattr(test_workflow._runner, "context"):
test_workflow._runner.context._checkpoint_storage = checkpoint_storage
# Create and save a checkpoint via injected storage
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, shared_state={"injected": True}
)
await checkpoint_storage.save_checkpoint(checkpoint)
# Verify checkpoint is accessible via storage (in this session)
storage_checkpoints = await checkpoint_storage.list_checkpoints()
assert len(storage_checkpoints) > 0
assert storage_checkpoints[0].checkpoint_id == checkpoint.checkpoint_id
@pytest.mark.asyncio
async def test_checkpoint_roundtrip_via_storage(self, checkpoint_manager, test_workflow):
"""Test checkpoint save/load roundtrip via storage adapter."""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_integration_test2"
# Create session conversation
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Set build-time storage for testing
checkpoint_storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
test_workflow._runner.context._checkpoint_storage = checkpoint_storage
# Create checkpoint
import uuid
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
messages={},
shared_state={"ready_to_resume": True},
)
checkpoint_id = await checkpoint_storage.save_checkpoint(checkpoint)
# Verify checkpoint can be loaded for resume
loaded = await checkpoint_storage.load_checkpoint(checkpoint_id)
assert loaded is not None
assert loaded.checkpoint_id == checkpoint_id
assert loaded.shared_state == {"ready_to_resume": True}
# Verify checkpoint is accessible via storage (for UI to list checkpoints)
checkpoints = await checkpoint_storage.list_checkpoints()
assert len(checkpoints) > 0
assert checkpoints[0].checkpoint_id == checkpoint_id
@pytest.mark.asyncio
async def test_workflow_auto_saves_checkpoints_to_injected_storage(self, checkpoint_manager, test_workflow):
"""Test that workflows automatically save checkpoints to our conversation-backed storage.
This is the critical end-to-end test that verifies the entire checkpoint flow:
1. Storage is set as build-time storage (simulates .with_checkpointing())
2. Workflow runs and pauses at HIL point (IDLE_WITH_PENDING_REQUESTS status)
3. Framework automatically saves checkpoint to our storage
4. Checkpoint is accessible via manager for UI to list/resume
Note: In production, DevUI passes checkpoint_storage to run_stream() as runtime parameter.
This test uses build-time injection to verify framework's checkpoint auto-save behavior.
"""
entity_id = "test_entity"
conversation_id = f"session_{entity_id}_integration_test3"
# Create session conversation
checkpoint_manager.conversation_store.create_conversation(
metadata={"entity_id": entity_id, "type": "workflow_session"}, conversation_id=conversation_id
)
# Set build-time storage to test automatic checkpoint saves
checkpoint_storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
test_workflow._runner.context._checkpoint_storage = checkpoint_storage
# Verify no checkpoints initially
checkpoints_before = await checkpoint_storage.list_checkpoints()
assert len(checkpoints_before) == 0
# Run workflow until it reaches IDLE_WITH_PENDING_REQUESTS (after checkpoint is created)
saw_request_event = False
async for event in test_workflow.run_stream(WorkflowTestData(value="test")):
if hasattr(event, "__class__"):
if event.__class__.__name__ == "RequestInfoEvent":
saw_request_event = True
# Wait for IDLE_WITH_PENDING_REQUESTS status (comes after checkpoint creation)
is_status_event = event.__class__.__name__ == "WorkflowStatusEvent"
has_pending_status = hasattr(event, "status") and "IDLE_WITH_PENDING_REQUESTS" in str(event.status)
if is_status_event and has_pending_status:
break
assert saw_request_event, "Test workflow should have emitted RequestInfoEvent"
# Verify checkpoint was AUTOMATICALLY saved to our storage by the framework
checkpoints_after = await checkpoint_storage.list_checkpoints()
assert len(checkpoints_after) > 0, "Workflow should have auto-saved checkpoint at HIL pause"
# Verify checkpoint has correct workflow_id
checkpoint = checkpoints_after[0]
assert checkpoint.workflow_id == test_workflow.id
@@ -0,0 +1,365 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for cleanup hook registration and execution."""
import asyncio
import tempfile
from pathlib import Path
import pytest
from agent_framework import AgentRunResponse, ChatMessage, Role, TextContent
from agent_framework_devui import register_cleanup
from agent_framework_devui._discovery import EntityDiscovery
@pytest.fixture(autouse=True)
def cleanup_registry():
"""Clear the cleanup registry before each test."""
import agent_framework_devui
agent_framework_devui._cleanup_registry.clear()
yield
agent_framework_devui._cleanup_registry.clear()
class MockAgent:
"""Mock agent for testing."""
def __init__(self, name: str = "TestAgent"):
self.id = f"test-{name.lower()}"
self.name = name
self.description = "Test agent for cleanup hooks"
self.cleanup_called = False
self.async_cleanup_called = False
async def run_stream(self, messages=None, *, thread=None, **kwargs):
"""Mock streaming run method."""
yield AgentRunResponse(
messages=[ChatMessage(role=Role.ASSISTANT, content=[TextContent(text="Test response")])],
inner_messages=[],
)
class MockCredential:
"""Mock credential object for testing cleanup."""
def __init__(self):
self.closed = False
async def close(self):
"""Mock async close method."""
self.closed = True
class MockSyncResource:
"""Mock synchronous resource for testing cleanup."""
def __init__(self):
self.closed = False
def close(self):
"""Mock sync close method."""
self.closed = True
# Test 1: Register single cleanup hook
async def test_register_cleanup_single_hook():
"""Test registering a single cleanup hook for an entity."""
agent = MockAgent("SingleHook")
credential = MockCredential()
# Register cleanup
register_cleanup(agent, credential.close)
# Verify credential not closed yet
assert not credential.closed
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Get cleanup hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 1
# Execute hook
await hooks[0]()
assert credential.closed
# Test 2: Register multiple cleanup hooks
async def test_register_cleanup_multiple_hooks():
"""Test registering multiple cleanup hooks for a single entity."""
agent = MockAgent("MultipleHooks")
credential1 = MockCredential()
credential2 = MockCredential()
sync_resource = MockSyncResource()
# Register multiple hooks at once
register_cleanup(agent, credential1.close, credential2.close, sync_resource.close)
# Verify nothing closed yet
assert not credential1.closed
assert not credential2.closed
assert not sync_resource.closed
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Get and execute hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 3
# Execute all hooks
for hook in hooks:
if asyncio.iscoroutinefunction(hook):
await hook()
else:
hook()
assert credential1.closed
assert credential2.closed
assert sync_resource.closed
# Test 3: Register cleanup hooks incrementally
async def test_register_cleanup_incremental():
"""Test registering cleanup hooks in multiple calls."""
agent = MockAgent("IncrementalHooks")
credential1 = MockCredential()
credential2 = MockCredential()
# Register hooks incrementally
register_cleanup(agent, credential1.close)
register_cleanup(agent, credential2.close)
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Should have both hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 2
# Execute all hooks
for hook in hooks:
await hook()
assert credential1.closed
assert credential2.closed
# Test 4: Test with no cleanup hooks
async def test_no_cleanup_hooks():
"""Test entity without any cleanup hooks registered."""
agent = MockAgent("NoHooks")
# Don't register any cleanup hooks
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Should return empty list
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 0
# Test 5: Test cleanup with async and sync hooks mixed
async def test_mixed_async_sync_hooks():
"""Test that both async and sync cleanup hooks work together."""
agent = MockAgent("MixedHooks")
async_resource = MockCredential()
sync_resource = MockSyncResource()
# Register both types
register_cleanup(agent, async_resource.close, sync_resource.close)
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Get and execute hooks with proper async/sync handling
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 2
import inspect
for hook in hooks:
if inspect.iscoroutinefunction(hook):
await hook()
else:
hook()
assert async_resource.closed
assert sync_resource.closed
# Test 6: Test error handling in cleanup hooks
async def test_cleanup_hook_error_handling():
"""Test that errors in cleanup hooks don't break execution."""
agent = MockAgent("ErrorHooks")
credential = MockCredential()
def failing_hook():
raise RuntimeError("Intentional error for testing")
# Register failing hook and valid hook
register_cleanup(agent, failing_hook, credential.close)
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Get hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 2
# Execute hooks with error handling (like _server.py does)
import inspect
for hook in hooks:
try:
if inspect.iscoroutinefunction(hook):
await hook()
else:
hook()
except Exception:
pass # Ignore errors like the server does
# Second hook should still execute despite first one failing
await credential.close()
assert credential.closed
# Test 7: Test ValueError when no hooks provided
def test_register_cleanup_no_hooks_error():
"""Test that register_cleanup raises ValueError when no hooks provided."""
agent = MockAgent("NoHooksError")
with pytest.raises(ValueError, match="At least one cleanup hook required"):
register_cleanup(agent)
# Test 8: Test file-based discovery with cleanup hooks
async def test_cleanup_with_file_based_discovery():
"""Test that cleanup hooks work with file-based entity discovery."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create agent directory
agent_dir = temp_path / "test_agent"
agent_dir.mkdir()
# Write agent module with cleanup registration
agent_file = agent_dir / "__init__.py"
agent_file.write_text("""
from agent_framework import AgentRunResponse, ChatMessage, Role, TextContent
from agent_framework_devui import register_cleanup
class MockCredential:
def __init__(self):
self.closed = False
async def close(self):
self.closed = True
# Create credential and agent
credential = MockCredential()
class TestAgent:
id = "test-agent"
name = "Test Agent"
description = "Test agent with cleanup"
async def run_stream(self, messages=None, *, thread=None, **kwargs):
yield AgentRunResponse(
messages=[ChatMessage(role=Role.ASSISTANT, content=[TextContent(text="Test")])],
inner_messages=[],
)
agent = TestAgent()
# Register cleanup at module level
register_cleanup(agent, credential.close)
""")
# Discover entities
discovery = EntityDiscovery(str(temp_path))
await discovery.discover_entities()
# Load the entity (triggers module import)
await discovery.load_entity("test_agent")
# Verify cleanup hooks were registered
hooks = discovery.get_cleanup_hooks("test_agent")
assert len(hooks) == 1
# Test 9: Test cleanup execution order
async def test_cleanup_execution_order():
"""Test that cleanup hooks execute in registration order."""
agent = MockAgent("OrderTest")
execution_order = []
def hook1():
execution_order.append(1)
def hook2():
execution_order.append(2)
def hook3():
execution_order.append(3)
# Register in specific order
register_cleanup(agent, hook1, hook2, hook3)
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Execute hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
for hook in hooks:
hook()
# Verify execution order
assert execution_order == [1, 2, 3]
# Test 10: Test custom cleanup logic
async def test_custom_cleanup_logic():
"""Test registering custom cleanup function with complex logic."""
agent = MockAgent("CustomCleanup")
cleanup_executed = False
resources_closed = []
async def custom_cleanup():
nonlocal cleanup_executed
cleanup_executed = True
resources_closed.append("credential")
resources_closed.append("session")
resources_closed.append("cache")
register_cleanup(agent, custom_cleanup)
# Simulate discovery and registration
discovery = EntityDiscovery()
entity_info = await discovery.create_entity_info_from_object(agent, entity_type="agent", source="in_memory")
discovery.register_entity(entity_info.id, entity_info, agent)
# Execute hooks
hooks = discovery.get_cleanup_hooks(entity_info.id)
assert len(hooks) == 1
await hooks[0]()
assert cleanup_executed
assert resources_closed == ["credential", "session", "cache"]
+62 -13
View File
@@ -100,17 +100,43 @@ async def test_executor_sync_execution(executor):
assert len(agents) > 0, "No agent entities found for testing"
agent_id = agents[0].id
# Use simplified routing: model = entity_id
# Use metadata.entity_id for routing
request = AgentFrameworkRequest(
model=agent_id, # Model IS the entity_id
metadata={"entity_id": agent_id},
input="test data",
stream=False,
)
response = await executor.execute_sync(request)
# With simplified routing, response.model reflects the actual agent_id
assert response.model == agent_id
# Response model should be 'devui' when not specified
assert response.model == "devui"
assert response.object == "response"
assert len(response.output) > 0
assert response.usage.total_tokens > 0
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="requires OpenAI API key")
async def test_executor_sync_execution_with_model(executor):
"""Test synchronous execution with model field specified."""
entities = await executor.discover_entities()
# Find an agent entity to test with
agents = [e for e in entities if e.type == "agent"]
assert len(agents) > 0, "No agent entities found for testing"
agent_id = agents[0].id
# Use metadata.entity_id for routing AND specify a model
request = AgentFrameworkRequest(
metadata={"entity_id": agent_id},
model="custom-model-name",
input="test data",
stream=False,
)
response = await executor.execute_sync(request)
# Response model should reflect the specified model
assert response.model == "custom-model-name"
assert response.object == "response"
assert len(response.output) > 0
assert response.usage.total_tokens > 0
@@ -126,9 +152,9 @@ async def test_executor_streaming_execution(executor):
assert len(agents) > 0, "No agent entities found for testing"
agent_id = agents[0].id
# Use simplified routing: model = entity_id
# Use metadata.entity_id for routing
request = AgentFrameworkRequest(
model=agent_id, # Model IS the entity_id
metadata={"entity_id": agent_id},
input="streaming test",
stream=True,
)
@@ -155,14 +181,14 @@ async def test_executor_invalid_entity_id(executor):
async def test_executor_missing_entity_id(executor):
"""Test get_entity_id returns model field (simplified routing)."""
"""Test get_entity_id returns metadata.entity_id."""
request = AgentFrameworkRequest(
model="my_agent",
metadata={"entity_id": "my_agent"},
input="test",
stream=False,
)
# With simplified routing, model field IS the entity_id
# entity_id is extracted from metadata
entity_id = request.get_entity_id()
assert entity_id == "my_agent"
@@ -212,6 +238,29 @@ def test_executor_parse_raw_falls_back_to_string():
assert parsed == "hi there"
def test_executor_parse_stringified_json_workflow_input():
"""Stringified JSON workflow input (from frontend JSON.stringify) is correctly parsed."""
from pydantic import BaseModel
class WorkflowInput(BaseModel):
input: str
metadata: dict | None = None
executor = AgentFrameworkExecutor(EntityDiscovery(None), MessageMapper())
start_executor = _DummyStartExecutor(handlers={WorkflowInput: lambda *_: None})
workflow = _DummyWorkflow(start_executor)
# Simulate frontend sending JSON.stringify({"input": "testing!", "metadata": {"key": "value"}})
stringified_json = '{"input": "testing!", "metadata": {"key": "value"}}'
parsed = executor._parse_raw_workflow_input(workflow, stringified_json)
# Should parse into WorkflowInput object
assert isinstance(parsed, WorkflowInput)
assert parsed.input == "testing!"
assert parsed.metadata == {"key": "value"}
async def test_executor_handles_non_streaming_agent():
"""Test executor can handle agents with only run() method (no run_stream)."""
from agent_framework import AgentRunResponse, AgentThread, ChatMessage, Role, TextContent
@@ -245,9 +294,9 @@ async def test_executor_handles_non_streaming_agent():
entity_info = await discovery.create_entity_info_from_object(agent, source="test")
discovery.register_entity(entity_info.id, entity_info, agent)
# Execute non-streaming agent (use simplified routing)
# Execute non-streaming agent (use metadata.entity_id for routing)
request = AgentFrameworkRequest(
model=entity_info.id, # Model IS the entity_id
metadata={"entity_id": entity_info.id},
input="hello",
stream=True, # DevUI always streams
)
@@ -289,9 +338,9 @@ class StreamingAgent:
entities = await executor.discover_entities()
if entities:
# Test sync execution (use simplified routing)
# Test sync execution (use metadata.entity_id for routing)
request = AgentFrameworkRequest(
model=entities[0].id, # Model IS the entity_id
metadata={"entity_id": entities[0].id},
input="test input",
stream=False,
)
+54 -4
View File
@@ -55,9 +55,9 @@ def mapper() -> MessageMapper:
@pytest.fixture
def test_request() -> AgentFrameworkRequest:
# Use simplified routing: model = entity_id
# Use metadata.entity_id for routing
return AgentFrameworkRequest(
model="test_agent", # Model IS the entity_id
metadata={"entity_id": "test_agent"},
input="Test input",
stream=True,
)
@@ -292,7 +292,7 @@ async def test_agent_lifecycle_events(mapper: MessageMapper, test_request: Agent
assert len(events) == 2 # Should emit response.created and response.in_progress
assert events[0].type == "response.created"
assert events[1].type == "response.in_progress"
assert events[0].response.model == "test_agent" # Should use model from request
assert events[0].response.model == "devui" # Should use 'devui' when model not specified in request
assert events[0].response.status == "in_progress"
# Test AgentCompletedEvent
@@ -415,12 +415,62 @@ async def test_executor_action_events(mapper: MessageMapper, test_request: Agent
assert "Executor failed" in str(events[0].item["error"]["message"])
async def test_magentic_agent_delta_creates_message_container(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""Test that MagenticAgentDeltaEvent creates message containers (Option A implementation)."""
# Create mock MagenticAgentDeltaEvent that mimics the real class
from dataclasses import dataclass
try:
from agent_framework import WorkflowEvent
@dataclass
class MagenticAgentDeltaEvent(WorkflowEvent): # Inherit from WorkflowEvent
agent_id: str
text: str | None = None
except ImportError:
# Fallback if WorkflowEvent is not available
@dataclass
class MagenticAgentDeltaEvent: # Use the expected name directly
agent_id: str
text: str | None = None
# First delta should create message container
first_delta = MagenticAgentDeltaEvent(agent_id="test_agent", text="Hello ")
events = await mapper.convert_event(first_delta, test_request)
# Should emit 3 events: message container, content part, and text delta
assert len(events) == 3
assert events[0].type == "response.output_item.added"
assert events[0].item.type == "message" # Message, not executor_action!
assert events[0].item.metadata["agent_id"] == "test_agent"
assert events[0].item.metadata["source"] == "magentic"
message_id = events[0].item.id
# Check text delta references the message ID
assert events[2].type == "response.output_text.delta"
assert events[2].item_id == message_id
assert events[2].delta == "Hello "
# Second delta should NOT create new container
second_delta = MagenticAgentDeltaEvent(agent_id="test_agent", text="world!")
events = await mapper.convert_event(second_delta, test_request)
# Only text delta, no new container
assert len(events) == 1
assert events[0].type == "response.output_text.delta"
assert events[0].item_id == message_id
if __name__ == "__main__":
# Simple test runner
async def run_all_tests() -> None:
mapper = MessageMapper()
test_request = AgentFrameworkRequest(
model="test",
metadata={"entity_id": "test"},
input="Test",
stream=True,
)
@@ -0,0 +1,266 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests using the official OpenAI SDK to call DevUI."""
import asyncio
import contextlib
import http.client
import json
import threading
import time
from collections.abc import Generator
from pathlib import Path
from urllib.parse import urlparse
import pytest
import uvicorn
from openai import OpenAI
from agent_framework_devui import DevServer
@pytest.fixture(scope="module")
def devui_server() -> Generator[str, None, None]:
"""Start a DevUI server for testing.
Yields:
Base URL of the running server.
"""
# Get samples directory
current_dir = Path(__file__).parent
samples_dir = current_dir.parent.parent.parent / "samples" / "getting_started" / "devui"
if not samples_dir.exists():
pytest.skip(f"Samples directory not found: {samples_dir}")
# Create and start server with port 0 to get a random available port
server = DevServer(
entities_dir=str(samples_dir.resolve()),
host="127.0.0.1",
port=0, # Use 0 to let OS assign a random available port
ui_enabled=False,
)
app = server.get_app()
server_config = uvicorn.Config(
app=app,
host="127.0.0.1",
port=0, # Use 0 to let OS assign a random available port
log_level="error",
ws="none", # Disable websockets to avoid deprecation warnings
)
server_instance = uvicorn.Server(server_config)
def run_server() -> None:
asyncio.run(server_instance.serve())
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Wait for server to start and get the actual port
max_retries = 20
actual_port = None
for _ in range(max_retries):
time.sleep(0.5)
# Get the actual port from the server instance
if hasattr(server_instance, "servers") and server_instance.servers:
for srv in server_instance.servers:
for socket in srv.sockets:
actual_port = socket.getsockname()[1]
break
if actual_port:
break
if actual_port:
# Verify server is responding
try:
conn = http.client.HTTPConnection("127.0.0.1", actual_port, timeout=5)
try:
conn.request("GET", "/health")
response = conn.getresponse()
if response.status == 200:
break
finally:
conn.close()
except Exception:
pass
if not actual_port:
pytest.skip("Server failed to start - could not determine port")
yield f"http://127.0.0.1:{actual_port}"
# Cleanup
with contextlib.suppress(Exception):
server_instance.should_exit = True
def test_openai_sdk_responses_create_with_entity_id(devui_server: str) -> None:
"""Test using OpenAI SDK with entity_id in metadata (no model parameter)."""
base_url = devui_server
client = OpenAI(base_url=f"{base_url}/v1", api_key="not-needed")
# Get available entities - extract host and port from base_url
parsed = urlparse(base_url)
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=10)
try:
conn.request("GET", "/v1/entities")
response = conn.getresponse()
entities = json.loads(response.read().decode("utf-8"))["entities"]
finally:
conn.close()
assert len(entities) > 0, "No entities discovered"
# Find an agent entity
agent = next((e for e in entities if e["type"] == "agent"), None)
if not agent:
pytest.skip("No agent entities found")
agent_id = agent["id"]
# Test non-streaming request with entity_id in metadata
response = client.responses.create(
metadata={"entity_id": agent_id},
input="What is 2+2?",
)
assert response.object == "response"
assert len(response.output) > 0
assert response.output[0].content is not None
def test_openai_sdk_responses_create_streaming(devui_server: str) -> None:
"""Test using OpenAI SDK with streaming enabled."""
base_url = devui_server
client = OpenAI(base_url=f"{base_url}/v1", api_key="not-needed")
# Get available entities - extract host and port from base_url
parsed = urlparse(base_url)
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=10)
try:
conn.request("GET", "/v1/entities")
response = conn.getresponse()
entities = json.loads(response.read().decode("utf-8"))["entities"]
finally:
conn.close()
assert len(entities) > 0, "No entities discovered"
# Find an agent entity
agent = next((e for e in entities if e["type"] == "agent"), None)
if not agent:
pytest.skip("No agent entities found")
agent_id = agent["id"]
# Test streaming request
stream = client.responses.create(
metadata={"entity_id": agent_id},
input="Count to 3",
stream=True,
)
events = []
for event in stream:
events.append(event)
if len(events) >= 100: # Limit for safety
break
assert len(events) > 0, "No events received from stream"
# Check that we got various event types
event_types = {event.type for event in events}
# Should have at least response.completed or some content events
assert len(event_types) > 0
def test_openai_sdk_with_conversations(devui_server: str) -> None:
"""Test using OpenAI SDK with conversation continuity."""
base_url = devui_server
client = OpenAI(base_url=f"{base_url}/v1", api_key="not-needed")
# Get available entities - extract host and port from base_url
parsed = urlparse(base_url)
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=10)
try:
conn.request("GET", "/v1/entities")
response = conn.getresponse()
entities = json.loads(response.read().decode("utf-8"))["entities"]
finally:
conn.close()
assert len(entities) > 0, "No entities discovered"
# Find an agent entity
agent = next((e for e in entities if e["type"] == "agent"), None)
if not agent:
pytest.skip("No agent entities found")
agent_id = agent["id"]
# Create a conversation
conversation = client.conversations.create(metadata={"agent_id": agent_id})
assert conversation.id is not None
# First turn
response1 = client.responses.create(
metadata={"entity_id": agent_id},
input="My name is Alice",
conversation=conversation.id,
)
assert response1.object == "response"
assert len(response1.output) > 0
# Second turn - test conversation continuity
response2 = client.responses.create(
metadata={"entity_id": agent_id},
input="What is my name?",
conversation=conversation.id,
)
assert response2.object == "response"
assert len(response2.output) > 0
# The agent should remember the name from the previous turn
# Note: This may not work with all agents, so we just verify we got a response
assert response2.output[0].content is not None
def test_openai_sdk_with_model_and_entity_id(devui_server: str) -> None:
"""Test that both model and entity_id can be specified together."""
base_url = devui_server
client = OpenAI(base_url=f"{base_url}/v1", api_key="not-needed")
# Get available entities - extract host and port from base_url
parsed = urlparse(base_url)
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=10)
try:
conn.request("GET", "/v1/entities")
response = conn.getresponse()
entities = json.loads(response.read().decode("utf-8"))["entities"]
finally:
conn.close()
assert len(entities) > 0, "No entities discovered"
# Find an agent entity
agent = next((e for e in entities if e["type"] == "agent"), None)
if not agent:
pytest.skip("No agent entities found")
agent_id = agent["id"]
# Test with both model and entity_id - entity_id should be used for routing
response = client.responses.create(
metadata={"entity_id": agent_id},
model="custom-model-name",
input="Hello",
)
assert response.object == "response"
# The response model should reflect what was specified
assert response.model == "custom-model-name"
assert len(response.output) > 0
@@ -4,13 +4,14 @@
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
import pytest
# Add parent package to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from agent_framework_devui._utils import generate_input_schema
from agent_framework_devui._utils import extract_response_type_from_executor, generate_input_schema
@dataclass
@@ -132,6 +133,99 @@ def test_schema_generation_error_handling():
pass
def test_extract_response_type_from_executor():
"""Test extraction of response type from @response_handler methods."""
try:
from agent_framework import Executor, WorkflowContext, handler, response_handler
from pydantic import BaseModel, Field
# Define test request and response types
@dataclass
class TestApprovalRequest:
"""Test request for approval."""
prompt: str
context: str
class TestDecision(BaseModel):
"""Test decision response."""
decision: Literal["approve", "reject"] = Field(description="User's decision")
reason: str = Field(description="Reason for decision", default="")
# Create test executor with @response_handler
class TestExecutor(Executor):
"""Test executor with response handler."""
def __init__(self):
super().__init__(id="test_executor")
@handler
async def handle_message(self, message: str, ctx: WorkflowContext) -> None:
"""Regular handler to satisfy executor requirements."""
# Request info that will be handled by response_handler
request = TestApprovalRequest(prompt="Test", context="Test context")
await ctx.request_info(request, TestDecision)
@response_handler
async def handle_approval(
self, original_request: TestApprovalRequest, response: TestDecision, ctx: WorkflowContext
) -> None:
"""Handle approval response."""
pass
# Test extraction
executor = TestExecutor()
extracted_type = extract_response_type_from_executor(executor, TestApprovalRequest)
# Verify correct type was extracted
assert extracted_type is not None, "Should extract response type from @response_handler"
assert extracted_type == TestDecision, f"Expected TestDecision, got {extracted_type}"
# Test full schema generation pipeline
schema = generate_input_schema(extracted_type)
assert schema is not None
assert isinstance(schema, dict)
assert "properties" in schema
assert "decision" in schema["properties"]
assert "enum" in schema["properties"]["decision"]
assert schema["properties"]["decision"]["enum"] == ["approve", "reject"]
except ImportError as e:
pytest.skip(f"Required dependencies not available: {e}")
def test_extract_response_type_no_match():
"""Test that extraction returns None when no matching handler exists."""
try:
from agent_framework import Executor, WorkflowContext, handler
@dataclass
class UnmatchedRequest:
"""Request type with no handler."""
data: str
class MinimalExecutor(Executor):
"""Executor with a handler but no matching response_handler."""
def __init__(self):
super().__init__(id="minimal_executor")
@handler
async def handle_message(self, message: str, ctx: WorkflowContext) -> None:
"""Regular handler."""
pass
executor = MinimalExecutor()
extracted_type = extract_response_type_from_executor(executor, UnmatchedRequest)
assert extracted_type is None, "Should return None when no matching handler exists"
except ImportError as e:
pytest.skip(f"Required dependencies not available: {e}")
if __name__ == "__main__":
# Simple test runner for manual execution
pytest.main([__file__, "-v"])

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