mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5419b9f38 | ||
|
|
03e47b5232 | ||
|
|
46ab47b9e1 | ||
|
|
094f9903b3 | ||
|
|
8b71f9459a |
+17
-1
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.2] - 2026-04-29
|
||||
|
||||
### Added
|
||||
- **agent-framework-azure-contentunderstanding**: New alpha package — Azure AI Content Understanding context provider that auto-analyzes file attachments (documents, images, audio, video) and injects structured results into the LLM context, with multi-document session state, configurable timeout, output filtering via `AnalysisSection`, and auto-registered `list_documents` / `get_analyzed_document` tools ([#4829](https://github.com/microsoft/agent-framework/pull/4829))
|
||||
- **agent-framework-foundry-hosting**: Add hosted Durable Workflow support — propagate full conversation history to workflow agents and wire `Workflow.as_agent()` end-to-end via the foundry hosting layer ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-orchestrations**: [BREAKING] Standardize orchestration terminal outputs as `AgentResponse` so `Workflow.as_agent()` returns the final answer only; aligns sequential-approval (`with_request_info`) and concurrent (`intermediate_outputs=True`) flows on the same output contract ([#5301](https://github.com/microsoft/agent-framework/pull/5301))
|
||||
- **agent-framework-core**, **agent-framework-declarative**: Preserve `Workflow.run()` shared state across calls so multi-turn `WorkflowAgent` invocations retain context, accept `list[Message]` input in the declarative start executor, and coerce `Enum` values when serializing PowerFx symbols ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
|
||||
- **dependencies**: Update workspace package dependencies and preserve `mcp[ws]` / `uvicorn[standard]` extras through override-dependencies in `/python` ([#5555](https://github.com/microsoft/agent-framework/pull/5555))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Fix observability spans not being correctly nested when using streaming ([#5552](https://github.com/microsoft/agent-framework/pull/5552))
|
||||
- **agent-framework-openai**: Fix `file_search` citations breaking the assistant-message history roundtrip — skip `hosted_file` content in the assistant role so the Responses API no longer rejects `input_file` ([#5557](https://github.com/microsoft/agent-framework/pull/5557))
|
||||
|
||||
## [1.2.1] - 2026-04-28
|
||||
|
||||
### Added
|
||||
@@ -1003,7 +1018,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.1...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...HEAD
|
||||
[1.2.2]: https://github.com/microsoft/agent-framework/compare/python-1.2.1...python-1.2.2
|
||||
[1.2.1]: https://github.com/microsoft/agent-framework/compare/python-1.2.0...python-1.2.1
|
||||
[1.2.0]: https://github.com/microsoft/agent-framework/compare/python-1.1.1...python-1.2.0
|
||||
[1.1.1]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...python-1.1.1
|
||||
|
||||
@@ -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.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"a2a-sdk>=0.3.5,<0.3.24",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"ag-ui-protocol>=0.1.16,<0.2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
|
||||
@@ -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.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260401"
|
||||
version = "1.0.0a260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,9 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0,<2",
|
||||
"azure-ai-contentunderstanding>=1.0.0,<1.1",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"agent-framework-foundry>=1.2.2,<2",
|
||||
"azure-ai-contentunderstanding>=1.0.1,<1.1",
|
||||
"aiohttp>=3.9,<4",
|
||||
"filetype>=1.2,<2",
|
||||
]
|
||||
|
||||
@@ -15,12 +15,10 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Agent, Content, Message
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
|
||||
+1
-3
@@ -15,12 +15,10 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Agent, AgentSession, Content, Message
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
|
||||
+1
-3
@@ -16,12 +16,10 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Agent, AgentSession, Content, Message
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
|
||||
+1
-3
@@ -16,13 +16,11 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Agent, AgentSession, Content, Message
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
|
||||
+1
-3
@@ -21,13 +21,11 @@ Run with DevUI:
|
||||
import os
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from agent_framework.foundry import ContentUnderstandingContextProvider
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# --- Auth ---
|
||||
|
||||
+5
-6
@@ -32,17 +32,16 @@ Run with DevUI:
|
||||
import os
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.foundry import (
|
||||
ContentUnderstandingContextProvider,
|
||||
FileSearchConfig,
|
||||
FoundryChatClient,
|
||||
)
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from openai import AzureOpenAI
|
||||
|
||||
from agent_framework.foundry import (
|
||||
ContentUnderstandingContextProvider,
|
||||
FileSearchConfig,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# --- Auth ---
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"azure-cosmos>=4.3.0,<5",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"agent-framework-durabletask",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -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.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"claude-agent-sdk>=0.1.36,<0.1.49",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -2890,6 +2891,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None
|
||||
self._wrap_inner: bool = False
|
||||
self._map_update: Callable[[Any], UpdateT | Awaitable[UpdateT]] | None = None
|
||||
self._pull_context_manager_factories: list[Callable[[], contextlib.AbstractContextManager[Any]]] = []
|
||||
|
||||
def map(
|
||||
self,
|
||||
@@ -3008,11 +3010,18 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> UpdateT:
|
||||
if self._iterator is None:
|
||||
stream = await self._get_stream()
|
||||
self._iterator = stream.__aiter__()
|
||||
try:
|
||||
update: UpdateT = await self._iterator.__anext__()
|
||||
with contextlib.ExitStack() as stack:
|
||||
for factory in self._pull_context_manager_factories:
|
||||
stack.enter_context(factory())
|
||||
# Resolve the underlying stream inside the pull contexts so that any
|
||||
# spans/contexts created during stream resolution (e.g. inner chat
|
||||
# completion spans created on the first pull of a wrapped agent stream)
|
||||
# inherit the active context (e.g. an outer agent invoke span).
|
||||
if self._iterator is None:
|
||||
stream = await self._get_stream()
|
||||
self._iterator = stream.__aiter__()
|
||||
update: UpdateT = await self._iterator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
self._consumed = True
|
||||
await self._run_cleanup_hooks()
|
||||
@@ -3038,9 +3047,25 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
update = hooked
|
||||
return update
|
||||
|
||||
async def _resolve_stream_with_pull_contexts(self) -> AsyncIterable[UpdateT]:
|
||||
"""Resolve the underlying stream while activating any registered pull context managers.
|
||||
|
||||
Used by ``__await__`` and ``get_final_response`` so that any spans/contexts created
|
||||
during stream resolution (e.g. when the source is an Awaitable that internally
|
||||
creates child telemetry spans) inherit the same active context as iterator pulls.
|
||||
``__anext__`` resolves the stream inside its own ExitStack and so calls ``_get_stream``
|
||||
directly.
|
||||
"""
|
||||
if self._stream is not None:
|
||||
return await self._get_stream()
|
||||
with contextlib.ExitStack() as stack:
|
||||
for factory in self._pull_context_manager_factories:
|
||||
stack.enter_context(factory())
|
||||
return await self._get_stream()
|
||||
|
||||
def __await__(self) -> Any:
|
||||
async def _wrap() -> ResponseStream[UpdateT, FinalT]:
|
||||
await self._get_stream()
|
||||
await self._resolve_stream_with_pull_contexts()
|
||||
return self
|
||||
|
||||
return _wrap().__await__()
|
||||
@@ -3064,10 +3089,12 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
"""
|
||||
if self._wrap_inner:
|
||||
if self._inner_stream is None:
|
||||
# Use _get_stream() to resolve the awaitable - this properly handles
|
||||
# Use _resolve_stream_with_pull_contexts() so that any spans/contexts
|
||||
# created while resolving the awaitable (e.g. inner telemetry spans)
|
||||
# inherit the same active context as iterator pulls. This also handles
|
||||
# the case where _stream_source and _inner_stream_source are the same
|
||||
# coroutine (e.g., from from_awaitable), avoiding double-await errors.
|
||||
await self._get_stream()
|
||||
await self._resolve_stream_with_pull_contexts()
|
||||
if self._inner_stream is None:
|
||||
raise RuntimeError("Inner stream not available")
|
||||
if not self._finalized and not self._consumed:
|
||||
@@ -3177,6 +3204,25 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
self._cleanup_hooks.append(hook)
|
||||
return self
|
||||
|
||||
def with_pull_context_manager(
|
||||
self,
|
||||
cm_factory: Callable[[], contextlib.AbstractContextManager[Any]],
|
||||
) -> ResponseStream[UpdateT, FinalT]:
|
||||
"""Register a context manager factory invoked around each underlying iterator pull.
|
||||
|
||||
The factory is called once per ``__anext__`` and the returned context manager wraps
|
||||
the await of the underlying iterator. This is useful for state that needs to be
|
||||
active while the inner async work runs - for example, attaching an OpenTelemetry
|
||||
span to the current context so child spans created by inner code (HTTP clients,
|
||||
tool execution) are correctly parented.
|
||||
|
||||
Because the context manager is entered and exited within the same ``__anext__``
|
||||
invocation, attach/detach style operations remain symmetric in the same async
|
||||
context regardless of where the stream is iterated.
|
||||
"""
|
||||
self._pull_context_manager_factories.append(cm_factory)
|
||||
return self
|
||||
|
||||
async def _run_cleanup_hooks(self) -> None:
|
||||
if self._cleanup_run:
|
||||
return
|
||||
|
||||
@@ -437,6 +437,13 @@ class WorkflowAgent(BaseAgent):
|
||||
yield event
|
||||
|
||||
elif checkpoint_id is not None:
|
||||
# Restore the prior workflow state from the checkpoint. Shared
|
||||
# state (e.g. accumulated conversation history maintained by the
|
||||
# workflow's executors) survives across turns because Workflow.run
|
||||
# no longer wipes state per call. Callers who want to deliver a
|
||||
# new user message after restore should make a second
|
||||
# `workflow.run(message=...)` call - they are NOT mutually
|
||||
# exclusive on the same instance, but each must be its own call.
|
||||
if streaming:
|
||||
async for event in self.workflow.run(
|
||||
stream=True,
|
||||
|
||||
@@ -278,7 +278,12 @@ class Runner:
|
||||
"Please rebuild the original workflow before resuming."
|
||||
)
|
||||
|
||||
# Restore state
|
||||
# Restore state. Clear first so import_state (which merges) does
|
||||
# not leak stale keys from a prior run on this Workflow instance.
|
||||
# This matters more now that Workflow.run() no longer wipes state
|
||||
# per call - the only reset point for shared state on a reused
|
||||
# instance is at restore time.
|
||||
self._state.clear()
|
||||
self._state.import_state(checkpoint.state)
|
||||
# Restore executor states using the restored state
|
||||
await self._restore_executor_states()
|
||||
|
||||
@@ -299,7 +299,7 @@ class Workflow(DictConvertible):
|
||||
async def _run_workflow_with_tracing(
|
||||
self,
|
||||
initial_executor_fn: Callable[[], Awaitable[None]] | None = None,
|
||||
reset_context: bool = True,
|
||||
is_continuation: bool = False,
|
||||
streaming: bool = False,
|
||||
function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
|
||||
@@ -310,13 +310,19 @@ class Workflow(DictConvertible):
|
||||
of external callers to maintain context across different workflow runs.
|
||||
|
||||
Args:
|
||||
initial_executor_fn: Optional function to execute initial executor
|
||||
reset_context: Whether to reset the context for a new run
|
||||
streaming: Whether to enable streaming mode for agents
|
||||
initial_executor_fn: Optional function to execute initial executor.
|
||||
is_continuation: True when this run is a continuation of prior
|
||||
work (a checkpoint restore or a responses-only replay) rather
|
||||
than a fresh new turn delivered via the start executor with
|
||||
``message=...``. Continuations preserve per-run accounting
|
||||
(iteration counter and run kwargs) from the prior turn;
|
||||
fresh-message runs reset them. Shared workflow state is
|
||||
preserved in both cases.
|
||||
streaming: Whether to enable streaming mode for agents.
|
||||
function_invocation_kwargs: Optional kwargs to store in State for function
|
||||
invocations in subagents
|
||||
invocations in subagents.
|
||||
client_kwargs: Optional kwargs to store in State for chat client
|
||||
invocations in subagents
|
||||
invocations in subagents.
|
||||
|
||||
Yields:
|
||||
WorkflowEvent: The events generated during the workflow execution.
|
||||
@@ -345,16 +351,26 @@ class Workflow(DictConvertible):
|
||||
in_progress = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
|
||||
yield in_progress # noqa: RUF070
|
||||
|
||||
# Reset context for a new run if supported
|
||||
if reset_context:
|
||||
# Per-run reset for fresh-message runs only. We deliberately
|
||||
# do NOT clear shared workflow state (`_state.clear()`) or the
|
||||
# runner context's in-flight messages (`reset_for_new_run()`)
|
||||
# here - state and pending work persist across `run()` calls
|
||||
# so that a `WorkflowAgent` can deliver multi-turn input on
|
||||
# the same instance and have prior turns' context survive.
|
||||
# Iteration counting and per-run kwargs ARE per-run though,
|
||||
# so they're reset here.
|
||||
if not is_continuation:
|
||||
self._runner.reset_iteration_count()
|
||||
self._runner.context.reset_for_new_run()
|
||||
self._state.clear()
|
||||
|
||||
# Store run kwargs in State so executors can access them.
|
||||
# Only overwrite when new kwargs are explicitly provided or state was
|
||||
# just cleared (fresh run). On continuation (reset_context=False) with
|
||||
# no new kwargs, preserve the kwargs from the original run.
|
||||
# Per-run kwargs semantics:
|
||||
# - On a fresh message run, prior kwargs go away (set to {}
|
||||
# by default, or to the new kwargs if provided). This
|
||||
# prevents stale kwargs from a prior turn leaking into the
|
||||
# current turn.
|
||||
# - On a continuation (checkpoint restore or responses), the
|
||||
# prior run's kwargs are preserved unless the caller
|
||||
# explicitly provides new kwargs.
|
||||
if function_invocation_kwargs is not None or client_kwargs is not None:
|
||||
combined_kwargs: dict[str, Any] = {}
|
||||
if function_invocation_kwargs is not None:
|
||||
@@ -366,11 +382,12 @@ class Workflow(DictConvertible):
|
||||
client_kwargs, "client_kwargs"
|
||||
)
|
||||
self._state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs)
|
||||
elif reset_context:
|
||||
elif not is_continuation:
|
||||
self._state.set(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
self._state.commit() # Commit immediately so kwargs are available
|
||||
|
||||
# Set streaming mode after reset
|
||||
# Set streaming mode (always set explicitly per run since
|
||||
# reset_for_new_run() no longer runs to clear it).
|
||||
self._runner_context.set_streaming(streaming)
|
||||
|
||||
# Execute initial setup if provided
|
||||
@@ -585,13 +602,31 @@ class Workflow(DictConvertible):
|
||||
if checkpoint_storage is not None:
|
||||
self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage)
|
||||
|
||||
initial_executor_fn, reset_context = self._resolve_execution_mode(
|
||||
message, responses, checkpoint_id, checkpoint_storage
|
||||
)
|
||||
# Async validation: a fresh-message run is only allowed when the
|
||||
# runner context has fully drained from any prior run. If it still
|
||||
# has in-flight executor messages, the prior run didn't complete -
|
||||
# the caller must either resume from a checkpoint or wait for the
|
||||
# prior run to drain. (Pending request_info events are intentionally
|
||||
# NOT blocked here: a follow-up run with message=... is the normal
|
||||
# way to deliver a response to those pending requests, e.g. via
|
||||
# WorkflowAgent._process_pending_requests.)
|
||||
# NOTE: _validate_run_params already enforces that ``message`` is
|
||||
# mutually exclusive with both ``checkpoint_id`` and ``responses``,
|
||||
# so we don't need to re-check those here.
|
||||
if message is not None and await self._runner.context.has_messages():
|
||||
raise RuntimeError(
|
||||
"Cannot start a new run with 'message' while in-flight executor "
|
||||
"messages remain from a prior run. Resume from a checkpoint "
|
||||
"(checkpoint_id=...) or wait for the prior run to complete. "
|
||||
"Workflows that need to recover from a mid-run failure must use "
|
||||
"checkpointing; there is no in-process recovery path."
|
||||
)
|
||||
|
||||
initial_executor_fn = self._resolve_execution_mode(message, responses, checkpoint_id, checkpoint_storage)
|
||||
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=initial_executor_fn,
|
||||
reset_context=reset_context,
|
||||
is_continuation=(message is None),
|
||||
streaming=streaming,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
@@ -674,12 +709,8 @@ class Workflow(DictConvertible):
|
||||
responses: Mapping[str, Any] | None,
|
||||
checkpoint_id: str | None,
|
||||
checkpoint_storage: CheckpointStorage | None,
|
||||
) -> tuple[Callable[[], Awaitable[None]], bool]:
|
||||
"""Determine the initial executor function and reset_context flag based on parameters.
|
||||
|
||||
Returns:
|
||||
A tuple of (initial_executor_fn, reset_context).
|
||||
"""
|
||||
) -> Callable[[], Awaitable[None]]:
|
||||
"""Determine the initial executor function based on parameters."""
|
||||
if responses is not None:
|
||||
if checkpoint_id is not None:
|
||||
# Combined: restore checkpoint then send responses
|
||||
@@ -689,13 +720,9 @@ class Workflow(DictConvertible):
|
||||
else:
|
||||
# Send responses only (requires pending requests in workflow state)
|
||||
initial_executor_fn = functools.partial(self._send_responses_internal, responses)
|
||||
return initial_executor_fn, False
|
||||
return initial_executor_fn
|
||||
# Regular run or checkpoint restoration
|
||||
initial_executor_fn = functools.partial(
|
||||
self._execute_with_message_or_checkpoint, message, checkpoint_id, checkpoint_storage
|
||||
)
|
||||
reset_context = message is not None and checkpoint_id is None
|
||||
return initial_executor_fn, reset_context
|
||||
return functools.partial(self._execute_with_message_or_checkpoint, message, checkpoint_id, checkpoint_storage)
|
||||
|
||||
async def _restore_and_send_responses(
|
||||
self,
|
||||
|
||||
@@ -15,7 +15,10 @@ from typing import Any
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AnalysisSection": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
|
||||
"AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
|
||||
"ContentUnderstandingContextProvider": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
|
||||
"ContentUnderstandingContextProvider": (
|
||||
"agent_framework_azure_contentunderstanding",
|
||||
"agent-framework-azure-contentunderstanding",
|
||||
),
|
||||
"DocumentStatus": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
|
||||
"FileSearchBackend": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
|
||||
"FileSearchConfig": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
# Install the relevant packages for full type support.
|
||||
|
||||
from agent_framework_anthropic import AnthropicFoundryClient, RawAnthropicFoundryClient
|
||||
from agent_framework_azure_contentunderstanding import (
|
||||
AnalysisSection,
|
||||
ContentUnderstandingContextProvider,
|
||||
DocumentStatus,
|
||||
FileSearchBackend,
|
||||
FileSearchConfig,
|
||||
from agent_framework_azure_contentunderstanding import ( # pyright: ignore[reportMissingImports]
|
||||
AnalysisSection, # pyright: ignore[reportUnknownVariableType]
|
||||
ContentUnderstandingContextProvider, # pyright: ignore[reportUnknownVariableType]
|
||||
DocumentStatus, # pyright: ignore[reportUnknownVariableType]
|
||||
FileSearchBackend, # pyright: ignore[reportUnknownVariableType]
|
||||
FileSearchConfig, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
from agent_framework_foundry import (
|
||||
FoundryAgent,
|
||||
|
||||
@@ -26,6 +26,7 @@ from time import perf_counter, time_ns
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, TypedDict, cast, overload
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from opentelemetry import context as otel_context
|
||||
from opentelemetry import metrics, trace
|
||||
|
||||
from . import __version__ as version_info
|
||||
@@ -1277,27 +1278,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
)
|
||||
|
||||
if stream:
|
||||
result_stream = cast(
|
||||
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
|
||||
super_get_response(
|
||||
messages=messages,
|
||||
stream=True,
|
||||
options=opts,
|
||||
compaction_strategy=compaction_strategy,
|
||||
tokenizer=tokenizer,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=merged_client_kwargs,
|
||||
),
|
||||
)
|
||||
span = _start_streaming_span(attributes, OtelAttr.REQUEST_MODEL)
|
||||
|
||||
# Create span directly without trace.use_span() context attachment.
|
||||
# Streaming spans are closed asynchronously in cleanup hooks, which run
|
||||
# in a different async context than creation — using use_span() would
|
||||
# cause "Failed to detach context" errors from OpenTelemetry.
|
||||
operation = attributes.get(OtelAttr.OPERATION, "operation")
|
||||
span_name = attributes.get(OtelAttr.REQUEST_MODEL, "unknown")
|
||||
span = get_tracer().start_span(f"{operation} {span_name}")
|
||||
span.set_attributes(attributes)
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
|
||||
_capture_messages(
|
||||
span=span,
|
||||
@@ -1319,6 +1301,24 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
def _record_duration() -> None:
|
||||
duration_state["duration"] = perf_counter() - start_time
|
||||
|
||||
try:
|
||||
result_stream = cast(
|
||||
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
|
||||
super_get_response(
|
||||
messages=messages,
|
||||
stream=True,
|
||||
options=opts,
|
||||
compaction_strategy=compaction_strategy,
|
||||
tokenizer=tokenizer,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=merged_client_kwargs,
|
||||
),
|
||||
)
|
||||
except Exception as exception:
|
||||
capture_exception(span=span, exception=exception, timestamp=time_ns())
|
||||
_close_span()
|
||||
raise
|
||||
|
||||
async def _finalize_stream() -> None:
|
||||
from ._types import ChatResponse
|
||||
|
||||
@@ -1357,11 +1357,18 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
|
||||
finally:
|
||||
_close_span()
|
||||
|
||||
# Register a weak reference callback to close the span if stream is garbage collected
|
||||
# without being consumed. This ensures spans don't leak if users don't consume streams.
|
||||
wrapped_stream: ResponseStream[ChatResponseUpdate, ChatResponse[Any]] = result_stream.with_cleanup_hook(
|
||||
_record_duration
|
||||
).with_cleanup_hook(_finalize_stream)
|
||||
# The pull context manager attaches the span around each underlying iterator pull so
|
||||
# that child spans created during the pull (e.g. HTTP requests, inner tool execution)
|
||||
# are parented under this chat span. Attach and detach happen in the same async
|
||||
# context as the pull, avoiding cross-context cleanup issues. The weakref finalizer
|
||||
# ensures the span is closed even if the stream is garbage collected without being
|
||||
# consumed.
|
||||
wrapped_stream: ResponseStream[ChatResponseUpdate, ChatResponse[Any]] = (
|
||||
result_stream
|
||||
.with_cleanup_hook(_record_duration)
|
||||
.with_cleanup_hook(_finalize_stream)
|
||||
.with_pull_context_manager(lambda: _activate_span(span))
|
||||
)
|
||||
weakref.finalize(wrapped_stream, _close_span)
|
||||
return wrapped_stream
|
||||
|
||||
@@ -1543,23 +1550,8 @@ class AgentTelemetryLayer:
|
||||
inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({})
|
||||
|
||||
if stream:
|
||||
try:
|
||||
run_result: object = execute()
|
||||
if isinstance(run_result, ResponseStream):
|
||||
result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType]
|
||||
elif isinstance(run_result, Awaitable):
|
||||
result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
|
||||
else:
|
||||
raise RuntimeError("Streaming telemetry requires a ResponseStream result.")
|
||||
except Exception:
|
||||
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token)
|
||||
INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token)
|
||||
raise
|
||||
span = _start_streaming_span(attributes, OtelAttr.AGENT_NAME)
|
||||
|
||||
operation = attributes.get(OtelAttr.OPERATION, "operation")
|
||||
span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown")
|
||||
span = get_tracer().start_span(f"{operation} {span_name}")
|
||||
span.set_attributes(attributes)
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
|
||||
_capture_messages(
|
||||
span=span,
|
||||
@@ -1581,6 +1573,21 @@ class AgentTelemetryLayer:
|
||||
def _record_duration() -> None:
|
||||
duration_state["duration"] = perf_counter() - start_time
|
||||
|
||||
try:
|
||||
run_result: object = execute()
|
||||
if isinstance(run_result, ResponseStream):
|
||||
result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType]
|
||||
elif isinstance(run_result, Awaitable):
|
||||
result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
|
||||
else:
|
||||
raise RuntimeError("Streaming telemetry requires a ResponseStream result.")
|
||||
except Exception as exception:
|
||||
capture_exception(span=span, exception=exception, timestamp=time_ns())
|
||||
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token)
|
||||
INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token)
|
||||
_close_span()
|
||||
raise
|
||||
|
||||
async def _finalize_stream() -> None:
|
||||
from ._types import AgentResponse
|
||||
|
||||
@@ -1620,9 +1627,18 @@ class AgentTelemetryLayer:
|
||||
INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token)
|
||||
_close_span()
|
||||
|
||||
wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = result_stream.with_cleanup_hook(
|
||||
_record_duration
|
||||
).with_cleanup_hook(_finalize_stream)
|
||||
# The pull context manager attaches the span around each underlying iterator pull so
|
||||
# that child spans created during the pull (e.g. inner chat completion spans from the
|
||||
# underlying ChatTelemetryLayer) are parented under this agent invoke span. Attach and
|
||||
# detach happen in the same async context as the pull, avoiding cross-context cleanup
|
||||
# issues. The weakref finalizer ensures the span is closed even if the stream is
|
||||
# garbage collected without being consumed.
|
||||
wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = (
|
||||
result_stream
|
||||
.with_cleanup_hook(_record_duration)
|
||||
.with_cleanup_hook(_finalize_stream)
|
||||
.with_pull_context_manager(lambda: _activate_span(span))
|
||||
)
|
||||
weakref.finalize(wrapped_stream, _close_span)
|
||||
return wrapped_stream
|
||||
|
||||
@@ -1809,6 +1825,27 @@ def get_function_span(
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _activate_span(span: trace.Span) -> Generator[None]:
|
||||
"""Attach ``span`` as the current span in the OpenTelemetry context.
|
||||
|
||||
Designed to be used as a per-pull context manager registered on a
|
||||
``ResponseStream`` via ``with_pull_context_manager``: it attaches the span
|
||||
before each underlying iterator pull and detaches immediately after, so
|
||||
child spans created during the pull (HTTP clients, inner chat completions,
|
||||
tool execution) are correctly parented under ``span``.
|
||||
|
||||
Because attach and detach happen within the same ``__anext__`` invocation
|
||||
(and therefore the same async task / contextvars context), there is no risk
|
||||
of "Failed to detach context" warnings from cross-context cleanup.
|
||||
"""
|
||||
token = otel_context.attach(trace.set_span_in_context(span))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
otel_context.detach(token)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _get_span(
|
||||
attributes: dict[str, Any],
|
||||
@@ -1831,6 +1868,29 @@ def _get_span(
|
||||
yield current_span
|
||||
|
||||
|
||||
def _start_streaming_span(attributes: dict[str, Any], span_name_attribute: str) -> trace.Span:
|
||||
"""Start a non-current span for a streaming operation.
|
||||
|
||||
Unlike :func:`_get_span`, the returned span is not attached to the current
|
||||
OpenTelemetry context. The caller is responsible for:
|
||||
|
||||
- Ending the span via cleanup hooks on the wrapped
|
||||
:class:`~agent_framework._types.ResponseStream`.
|
||||
- Activating the span around each iterator pull via
|
||||
:func:`_activate_span` registered with ``with_pull_context_manager`` so
|
||||
that child spans created during stream production inherit it as parent.
|
||||
|
||||
Streaming spans are closed asynchronously in cleanup hooks that run in a
|
||||
different async context than creation, so attaching the span at creation
|
||||
time would cause "Failed to detach context" errors from OpenTelemetry.
|
||||
"""
|
||||
operation = attributes.get(OtelAttr.OPERATION, "operation")
|
||||
span_name = attributes.get(span_name_attribute, "unknown")
|
||||
span = get_tracer().start_span(f"{operation} {span_name}")
|
||||
span.set_attributes(attributes)
|
||||
return span
|
||||
|
||||
|
||||
def _get_instructions_from_options(options: Any) -> str | list[str] | None:
|
||||
"""Extract instructions from options dict."""
|
||||
if options is None:
|
||||
|
||||
@@ -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.2.1"
|
||||
version = "1.2.2"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -3313,3 +3313,487 @@ async def test_agent_invoke_span_aggregates_usage_on_max_iterations_exhaustion(s
|
||||
# The invoke_agent span must aggregate usage from the in-loop call and the final exhaustion call
|
||||
assert agent_span.attributes.get(OtelAttr.INPUT_TOKENS) == 500
|
||||
assert agent_span.attributes.get(OtelAttr.OUTPUT_TOKENS) == 100
|
||||
|
||||
|
||||
# region Test span nesting (parent-child relationships)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
async def test_chat_span_nested_under_agent_span(span_exporter: InMemorySpanExporter, stream: bool):
|
||||
"""The inner chat span must be a child of the outer agent invoke span."""
|
||||
|
||||
class NestedChatClient(ChatTelemetryLayer, BaseChatClient[Any]):
|
||||
def service_url(self):
|
||||
return "https://test.example.com"
|
||||
|
||||
def _inner_get_response(
|
||||
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[Content.from_text("Hello")], role="assistant")
|
||||
yield ChatResponseUpdate(
|
||||
contents=[Content.from_text(" world")], role="assistant", finish_reason="stop"
|
||||
)
|
||||
|
||||
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
|
||||
return ChatResponse(
|
||||
messages=[Message(role="assistant", contents=["Hello world"])],
|
||||
response_id="resp_1",
|
||||
usage_details=UsageDetails(input_token_count=3, output_token_count=4),
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
return ResponseStream(_stream(), finalizer=_finalize)
|
||||
|
||||
async def _get() -> ChatResponse:
|
||||
return ChatResponse(
|
||||
messages=[Message(role="assistant", contents=["Hello world"])],
|
||||
response_id="resp_1",
|
||||
usage_details=UsageDetails(input_token_count=3, output_token_count=4),
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
return _get()
|
||||
|
||||
agent = Agent(
|
||||
client=NestedChatClient(),
|
||||
id="nested_agent_id",
|
||||
name="nested_agent",
|
||||
default_options={"model": "NestedModel"},
|
||||
)
|
||||
|
||||
span_exporter.clear()
|
||||
if stream:
|
||||
result_stream = agent.run("Test message", stream=True)
|
||||
async for _ in result_stream:
|
||||
pass
|
||||
await result_stream.get_final_response()
|
||||
else:
|
||||
await agent.run("Test message")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 2
|
||||
|
||||
span_by_op = {s.attributes[OtelAttr.OPERATION.value]: s for s in spans}
|
||||
agent_span = span_by_op[OtelAttr.AGENT_INVOKE_OPERATION]
|
||||
chat_span = span_by_op[OtelAttr.CHAT_COMPLETION_OPERATION]
|
||||
|
||||
# Agent span has no parent (it is the root)
|
||||
assert agent_span.parent is None
|
||||
|
||||
# Chat span's parent must be the agent span
|
||||
assert chat_span.parent is not None
|
||||
assert chat_span.parent.span_id == agent_span.context.span_id
|
||||
assert chat_span.parent.trace_id == agent_span.context.trace_id
|
||||
|
||||
# Both spans must share the same trace
|
||||
assert chat_span.context.trace_id == agent_span.context.trace_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
async def test_function_call_spans_nested_under_agent_span(span_exporter: InMemorySpanExporter, stream: bool):
|
||||
"""All inner spans (chat completions and execute_tool) must be children of the agent span."""
|
||||
from agent_framework import Content
|
||||
from agent_framework._tools import FunctionInvocationLayer
|
||||
|
||||
@tool(name="get_weather", description="Get the weather for a location")
|
||||
def get_weather(location: str) -> str:
|
||||
return f"The weather in {location} is sunny."
|
||||
|
||||
class NestedToolChatClient(FunctionInvocationLayer, ChatTelemetryLayer, BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.call_count = 0
|
||||
|
||||
def service_url(self):
|
||||
return "https://test.example.com"
|
||||
|
||||
def _inner_get_response(
|
||||
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
self.call_count += 1
|
||||
is_first = self.call_count == 1
|
||||
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
if is_first:
|
||||
yield ChatResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="get_weather",
|
||||
arguments='{"location": "Seattle"}',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
)
|
||||
else:
|
||||
yield ChatResponseUpdate(
|
||||
contents=[Content.from_text("The weather in Seattle is sunny!")],
|
||||
role="assistant",
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
|
||||
return ChatResponse.from_updates(updates)
|
||||
|
||||
return ResponseStream(_stream(), finalizer=_finalize)
|
||||
|
||||
async def _get() -> ChatResponse:
|
||||
if is_first:
|
||||
return ChatResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="get_weather",
|
||||
arguments='{"location": "Seattle"}',
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
return ChatResponse(
|
||||
messages=[Message(role="assistant", contents=["The weather in Seattle is sunny!"])],
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
return _get()
|
||||
|
||||
agent = Agent(
|
||||
client=NestedToolChatClient(),
|
||||
id="tool_agent_id",
|
||||
name="tool_agent",
|
||||
default_options={"model": "ToolModel", "tools": [get_weather], "tool_choice": "auto"},
|
||||
)
|
||||
|
||||
span_exporter.clear()
|
||||
if stream:
|
||||
result_stream = agent.run("What's the weather in Seattle?", stream=True)
|
||||
async for _ in result_stream:
|
||||
pass
|
||||
await result_stream.get_final_response()
|
||||
else:
|
||||
await agent.run("What's the weather in Seattle?")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
|
||||
invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
|
||||
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
|
||||
tool_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.TOOL_EXECUTION_OPERATION]
|
||||
|
||||
assert len(invoke_spans) == 1, f"Expected 1 invoke_agent span, got {len(invoke_spans)}"
|
||||
assert len(chat_spans) == 2, f"Expected 2 chat spans, got {len(chat_spans)}"
|
||||
assert len(tool_spans) == 1, f"Expected 1 execute_tool span, got {len(tool_spans)}"
|
||||
|
||||
agent_span = invoke_spans[0]
|
||||
assert agent_span.parent is None
|
||||
|
||||
# All inner spans must be parented under the agent invoke span
|
||||
for inner in (*chat_spans, *tool_spans):
|
||||
assert inner.parent is not None, f"Span {inner.name} has no parent"
|
||||
assert inner.parent.span_id == agent_span.context.span_id, (
|
||||
f"Span {inner.name} parent={inner.parent.span_id} != agent={agent_span.context.span_id}"
|
||||
)
|
||||
assert inner.context.trace_id == agent_span.context.trace_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
async def test_chat_span_nested_under_explicit_outer_span(
|
||||
span_exporter: InMemorySpanExporter, mock_chat_client, stream: bool
|
||||
):
|
||||
"""Chat telemetry spans (including streaming) must inherit a user-provided outer span as parent."""
|
||||
from agent_framework.observability import get_tracer
|
||||
|
||||
client = mock_chat_client()
|
||||
span_exporter.clear()
|
||||
|
||||
tracer = get_tracer()
|
||||
with tracer.start_as_current_span("outer") as outer_span:
|
||||
outer_ctx = outer_span.get_span_context()
|
||||
if stream:
|
||||
stream_obj = client.get_response(
|
||||
stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"}
|
||||
)
|
||||
async for _ in stream_obj:
|
||||
pass
|
||||
await stream_obj.get_final_response()
|
||||
else:
|
||||
await client.get_response(messages=[Message(role="user", contents=["Test"])], options={"model": "Test"})
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
|
||||
assert len(chat_spans) == 1
|
||||
chat_span = chat_spans[0]
|
||||
|
||||
assert chat_span.parent is not None
|
||||
assert chat_span.parent.span_id == outer_ctx.span_id
|
||||
assert chat_span.context.trace_id == outer_ctx.trace_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
async def test_http_span_nested_under_chat_span(span_exporter: InMemorySpanExporter, stream: bool):
|
||||
"""A span created inside ``_inner_get_response`` (e.g. an HTTP client call to the LLM provider)
|
||||
must be parented under the chat completion span.
|
||||
|
||||
This validates that the chat span context is active while the inner client implementation
|
||||
runs, both for non-streaming responses and while streaming updates are being pulled.
|
||||
"""
|
||||
from agent_framework.observability import get_tracer
|
||||
|
||||
tracer = get_tracer()
|
||||
|
||||
class HttpEmittingClient(ChatTelemetryLayer, BaseChatClient[Any]):
|
||||
def service_url(self):
|
||||
return "https://test.example.com"
|
||||
|
||||
def _inner_get_response(
|
||||
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
# Simulate an HTTP request to the model provider while producing the stream.
|
||||
with tracer.start_as_current_span("HTTP POST"):
|
||||
pass
|
||||
yield ChatResponseUpdate(contents=[Content.from_text("hi")], role="assistant", finish_reason="stop")
|
||||
|
||||
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
|
||||
return ChatResponse.from_updates(updates)
|
||||
|
||||
return ResponseStream(_stream(), finalizer=_finalize)
|
||||
|
||||
async def _get() -> ChatResponse:
|
||||
# Simulate an HTTP request to the model provider during the call.
|
||||
with tracer.start_as_current_span("HTTP POST"):
|
||||
pass
|
||||
return ChatResponse(
|
||||
messages=[Message(role="assistant", contents=["done"])],
|
||||
usage_details=UsageDetails(input_token_count=1, output_token_count=1),
|
||||
)
|
||||
|
||||
return _get()
|
||||
|
||||
span_exporter.clear()
|
||||
client = HttpEmittingClient()
|
||||
if stream:
|
||||
result_stream = client.get_response(
|
||||
stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"}
|
||||
)
|
||||
async for _ in result_stream:
|
||||
pass
|
||||
await result_stream.get_final_response()
|
||||
else:
|
||||
await client.get_response(messages=[Message(role="user", contents=["Test"])], options={"model": "Test"})
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
|
||||
http_spans = [s for s in spans if s.name == "HTTP POST"]
|
||||
assert len(chat_spans) == 1
|
||||
assert len(http_spans) == 1
|
||||
|
||||
chat_span = chat_spans[0]
|
||||
http_span = http_spans[0]
|
||||
|
||||
assert http_span.parent is not None
|
||||
assert http_span.parent.span_id == chat_span.context.span_id
|
||||
assert http_span.context.trace_id == chat_span.context.trace_id
|
||||
|
||||
|
||||
# region Test ResponseStream.with_pull_context_manager
|
||||
|
||||
|
||||
async def test_with_pull_context_manager_enters_and_exits_per_pull():
|
||||
"""The registered factory is entered and exited symmetrically around each iterator pull."""
|
||||
import contextlib
|
||||
|
||||
events: list[str] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def cm():
|
||||
events.append("enter")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append("exit")
|
||||
|
||||
async def src() -> AsyncIterable[int]:
|
||||
yield 1
|
||||
yield 2
|
||||
|
||||
stream: ResponseStream[int, list[int]] = ResponseStream(src(), finalizer=lambda updates: list(updates))
|
||||
stream.with_pull_context_manager(cm)
|
||||
|
||||
pulled = [u async for u in stream]
|
||||
|
||||
assert pulled == [1, 2]
|
||||
# Enter/exit must be balanced and there must be at least one pair per yielded update.
|
||||
assert events.count("enter") == events.count("exit")
|
||||
assert events.count("enter") >= 2
|
||||
# Verify symmetric ordering (no overlapping pairs).
|
||||
for i in range(0, len(events), 2):
|
||||
assert events[i] == "enter"
|
||||
assert events[i + 1] == "exit"
|
||||
|
||||
|
||||
async def test_with_pull_context_manager_exits_on_iteration_error():
|
||||
"""The pull context is exited even when the underlying stream raises mid-iteration."""
|
||||
import contextlib
|
||||
|
||||
events: list[str] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def cm():
|
||||
events.append("enter")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append("exit")
|
||||
|
||||
async def src() -> AsyncIterable[int]:
|
||||
yield 1
|
||||
raise RuntimeError("boom")
|
||||
|
||||
stream: ResponseStream[int, list[int]] = ResponseStream(src(), finalizer=lambda updates: list(updates))
|
||||
stream.with_pull_context_manager(cm)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
async for _ in stream:
|
||||
pass
|
||||
|
||||
# Enter/exit balanced even on the failing pull.
|
||||
assert events.count("enter") == events.count("exit")
|
||||
assert events.count("enter") >= 2
|
||||
|
||||
|
||||
async def test_with_pull_context_manager_wraps_stream_resolution_via_await():
|
||||
"""Awaiting a ``from_awaitable`` stream resolves the inner stream under the pull contexts."""
|
||||
import contextlib
|
||||
|
||||
events: list[str] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def cm():
|
||||
events.append("enter")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append("exit")
|
||||
|
||||
async def inner() -> AsyncIterable[int]:
|
||||
yield 1
|
||||
|
||||
async def make_stream() -> ResponseStream[int, list[int]]:
|
||||
# Record that we resolve while a pull context is active.
|
||||
events.append("resolving")
|
||||
return ResponseStream(inner(), finalizer=lambda updates: list(updates))
|
||||
|
||||
stream: ResponseStream[int, list[int]] = ResponseStream.from_awaitable(make_stream())
|
||||
stream.with_pull_context_manager(cm)
|
||||
|
||||
await stream # Triggers _resolve_stream_with_pull_contexts via __await__
|
||||
|
||||
assert "resolving" in events
|
||||
resolve_index = events.index("resolving")
|
||||
assert events[resolve_index - 1] == "enter" # Pull context active during resolution
|
||||
|
||||
|
||||
# region Test streaming telemetry error paths
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_chat_streaming_super_failure_closes_span(span_exporter: InMemorySpanExporter, enable_sensitive_data):
|
||||
"""If the underlying client raises synchronously when constructing the stream, the chat
|
||||
span is ended and the exception is recorded (no span leak)."""
|
||||
|
||||
class FailingClient(ChatTelemetryLayer, BaseChatClient[Any]):
|
||||
def service_url(self):
|
||||
return "https://test.example.com"
|
||||
|
||||
def _inner_get_response(
|
||||
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
|
||||
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
||||
raise RuntimeError("inner failed")
|
||||
|
||||
span_exporter.clear()
|
||||
client = FailingClient()
|
||||
with pytest.raises(RuntimeError, match="inner failed"):
|
||||
client.get_response(stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"})
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
|
||||
assert len(chat_spans) == 1
|
||||
assert chat_spans[0].status.status_code == StatusCode.ERROR
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
|
||||
async def test_agent_streaming_execute_failure_closes_span_and_resets_contextvars(
|
||||
span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""If ``execute()`` raises synchronously during streaming agent invocation, the agent span is
|
||||
ended, the exception is recorded, and the telemetry contextvars are reset."""
|
||||
from agent_framework.observability import (
|
||||
INNER_ACCUMULATED_USAGE,
|
||||
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS,
|
||||
)
|
||||
|
||||
class _FailingExecuteAgent:
|
||||
AGENT_PROVIDER_NAME = "test_provider"
|
||||
|
||||
def __init__(self):
|
||||
self._id = "failing_execute"
|
||||
self._name = "Failing Execute"
|
||||
self._description = "Agent whose stream call raises synchronously"
|
||||
self._default_options: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return self._description
|
||||
|
||||
@property
|
||||
def default_options(self):
|
||||
return self._default_options
|
||||
|
||||
def run(self, messages=None, *, stream: bool = False, session=None, **kwargs):
|
||||
if stream:
|
||||
raise RuntimeError("execute failed")
|
||||
raise NotImplementedError
|
||||
|
||||
class FailingExecuteAgent(AgentTelemetryLayer, _FailingExecuteAgent):
|
||||
pass
|
||||
|
||||
# Sentinel values to detect that contextvars were reset to their pre-call state.
|
||||
sentinel_fields: set[str] = set()
|
||||
sentinel_usage: dict[str, Any] = {}
|
||||
fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set(sentinel_fields)
|
||||
usage_token = INNER_ACCUMULATED_USAGE.set(sentinel_usage)
|
||||
try:
|
||||
agent = FailingExecuteAgent()
|
||||
span_exporter.clear()
|
||||
with pytest.raises(RuntimeError, match="execute failed"):
|
||||
agent.run(messages="Hello", stream=True)
|
||||
|
||||
# Contextvars must be back to the sentinel values registered before the call.
|
||||
assert INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.get() is sentinel_fields
|
||||
assert INNER_ACCUMULATED_USAGE.get() is sentinel_usage
|
||||
finally:
|
||||
INNER_ACCUMULATED_USAGE.reset(usage_token)
|
||||
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(fields_token)
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
agent_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
|
||||
assert len(agent_spans) == 1
|
||||
assert agent_spans[0].status.status_code == StatusCode.ERROR
|
||||
|
||||
@@ -488,8 +488,13 @@ class StateTrackingExecutor(Executor):
|
||||
await ctx.yield_output(existing_messages.copy()) # type: ignore
|
||||
|
||||
|
||||
async def test_workflow_multiple_runs_no_state_collision():
|
||||
"""Test that running the same workflow instance multiple times doesn't have state collision."""
|
||||
async def test_workflow_multiple_runs_preserve_state():
|
||||
"""Test that running the same workflow instance multiple times preserves shared state.
|
||||
|
||||
State preservation is the new default - calling ``Workflow.run`` repeatedly
|
||||
on the same instance behaves like a chat agent maintaining memory across
|
||||
turns. Callers that want fresh state should rebuild the Workflow.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
|
||||
@@ -503,29 +508,45 @@ async def test_workflow_multiple_runs_no_state_collision():
|
||||
.build()
|
||||
)
|
||||
|
||||
# Run 1: Should only see messages from run 1
|
||||
# Run 1: Single record from run 1
|
||||
result1 = await workflow.run(StateTrackingMessage(data="message1", run_id="run1"))
|
||||
assert result1.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs1 = result1.get_outputs()
|
||||
assert outputs1[0] == ["run1:message1"]
|
||||
|
||||
# Run 2: Should only see messages from run 2, not run 1
|
||||
# Run 2: State from run 1 persists; run 2's record appends.
|
||||
result2 = await workflow.run(StateTrackingMessage(data="message2", run_id="run2"))
|
||||
assert result2.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs2 = result2.get_outputs()
|
||||
assert outputs2[0] == ["run2:message2"] # Should NOT contain run1 data
|
||||
assert outputs2[0] == ["run1:message1", "run2:message2"]
|
||||
|
||||
# Run 3: Should only see messages from run 3
|
||||
# Run 3: Same - all three accumulate.
|
||||
result3 = await workflow.run(StateTrackingMessage(data="message3", run_id="run3"))
|
||||
assert result3.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs3 = result3.get_outputs()
|
||||
assert outputs3[0] == ["run3:message3"] # Should NOT contain run1 or run2 data
|
||||
assert outputs3[0] == ["run1:message1", "run2:message2", "run3:message3"]
|
||||
|
||||
# Verify that each run only processed its own message
|
||||
# This confirms that the checkpointable context properly resets between runs
|
||||
assert outputs1[0] != outputs2[0]
|
||||
assert outputs2[0] != outputs3[0]
|
||||
assert outputs1[0] != outputs3[0]
|
||||
|
||||
async def test_workflow_multiple_runs_no_state_collision_after_rebuild():
|
||||
"""Rebuilding the Workflow gives a fresh shared-state slate."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
|
||||
def _build():
|
||||
executor = StateTrackingExecutor(id="state_executor")
|
||||
return (
|
||||
WorkflowBuilder(start_executor=executor, checkpoint_storage=storage)
|
||||
.add_edge(executor, executor)
|
||||
.build()
|
||||
)
|
||||
|
||||
wf1 = _build()
|
||||
result1 = await wf1.run(StateTrackingMessage(data="message1", run_id="run1"))
|
||||
assert result1.get_outputs()[0] == ["run1:message1"]
|
||||
|
||||
wf2 = _build()
|
||||
result2 = await wf2.run(StateTrackingMessage(data="message2", run_id="run2"))
|
||||
assert result2.get_outputs()[0] == ["run2:message2"]
|
||||
|
||||
|
||||
async def test_workflow_checkpoint_runtime_only_configuration(
|
||||
@@ -932,6 +953,31 @@ async def test_agent_streaming_vs_non_streaming() -> None:
|
||||
assert accumulated_text == "Hello World", f"Expected 'Hello World', got '{accumulated_text}'"
|
||||
|
||||
|
||||
async def test_workflow_run_inflight_messages_guard(simple_executor: Executor) -> None:
|
||||
"""``run(message=...)`` must reject in-flight executor messages from a prior run.
|
||||
|
||||
Workflows preserve state and pending messages across :meth:`Workflow.run`
|
||||
calls. If a prior run aborted before the runner drained those pending
|
||||
messages (e.g. it raised :class:`WorkflowConvergenceException`), the next
|
||||
fresh-message call should fail loudly instead of silently mixing the
|
||||
leftover messages with the new turn. The supported recovery path is to
|
||||
resume from a checkpoint; there is no in-process recovery hatch.
|
||||
"""
|
||||
workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build()
|
||||
test_message = WorkflowMessage(data="test", source_id="test", target_id=None)
|
||||
|
||||
# Simulate an aborted prior run by leaving a message in the runner context.
|
||||
workflow._runner.context._messages["test"] = [test_message]
|
||||
assert await workflow._runner.context.has_messages()
|
||||
|
||||
with pytest.raises(RuntimeError, match="in-flight executor messages"):
|
||||
await workflow.run(test_message)
|
||||
|
||||
with pytest.raises(RuntimeError, match="in-flight executor messages"):
|
||||
async for _ in workflow.run(test_message, stream=True):
|
||||
pass
|
||||
|
||||
|
||||
async def test_workflow_run_parameter_validation(simple_executor: Executor) -> None:
|
||||
"""Test that stream properly validate parameter combinations."""
|
||||
workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build()
|
||||
@@ -942,13 +988,15 @@ async def test_workflow_run_parameter_validation(simple_executor: Executor) -> N
|
||||
result = await workflow.run(test_message)
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
# Invalid: both message and checkpoint_id
|
||||
# Invalid: message + checkpoint_id (mutually exclusive). Multi-turn
|
||||
# state preservation is handled by Workflow.run preserving state across
|
||||
# calls, so the host pattern is two separate calls (restore-then-run),
|
||||
# not a single combined call.
|
||||
with pytest.raises(ValueError, match="Cannot provide both 'message' and 'checkpoint_id'"):
|
||||
await workflow.run(test_message, checkpoint_id="fake_id")
|
||||
await workflow.run(test_message, checkpoint_id="some-checkpoint")
|
||||
|
||||
# Invalid: both message and checkpoint_id (streaming)
|
||||
with pytest.raises(ValueError, match="Cannot provide both 'message' and 'checkpoint_id'"):
|
||||
async for _ in workflow.run(test_message, checkpoint_id="fake_id", stream=True):
|
||||
async for _ in workflow.run(test_message, checkpoint_id="some-checkpoint", stream=True):
|
||||
pass
|
||||
|
||||
# Invalid: none of message or checkpoint_id
|
||||
|
||||
+137
-3
@@ -32,10 +32,12 @@ import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal as _Decimal
|
||||
from enum import Enum
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
Message,
|
||||
WorkflowContext,
|
||||
)
|
||||
from agent_framework._workflows._state import State
|
||||
@@ -120,7 +122,20 @@ def _make_powerfx_safe(value: Any) -> Any:
|
||||
Returns:
|
||||
A PowerFx-safe representation of the value
|
||||
"""
|
||||
if value is None or isinstance(value, _POWERFX_SAFE_TYPES):
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
# Enum coercion must run BEFORE the primitive type check: many MAF
|
||||
# enums (e.g. MessageRole) are ``str``-subclass enums, so they pass
|
||||
# ``isinstance(v, str)`` but pythonnet refuses to convert them to
|
||||
# ``System.String`` and raises ``'MessageRole' value cannot be
|
||||
# converted to System.<X>'`` for every PowerFx primitive type. Reduce
|
||||
# to the underlying value (or its string form) so PowerFx sees a
|
||||
# plain ``str``/``int``.
|
||||
if isinstance(value, Enum):
|
||||
return _make_powerfx_safe(value.value)
|
||||
|
||||
if isinstance(value, _POWERFX_SAFE_TYPES):
|
||||
return value
|
||||
|
||||
if isinstance(value, dict):
|
||||
@@ -197,6 +212,16 @@ class DeclarativeWorkflowState:
|
||||
result = self._state.get(DECLARATIVE_STATE_KEY)
|
||||
return cast(DeclarativeStateData, result)
|
||||
|
||||
def is_initialized(self) -> bool:
|
||||
"""Return True when declarative state has been initialized.
|
||||
|
||||
Useful for distinguishing a fresh start from a continuation: when
|
||||
Workflow state preserves data across run() calls (multi-turn
|
||||
scenarios), the start executor needs to avoid calling initialize()
|
||||
and clobbering the prior turn's Conversation/Local/System data.
|
||||
"""
|
||||
return self._state.get(DECLARATIVE_STATE_KEY) is not None
|
||||
|
||||
def set_state_data(self, data: DeclarativeStateData) -> None:
|
||||
"""Set the full state data dict in state."""
|
||||
self._state.set(DECLARATIVE_STATE_KEY, data)
|
||||
@@ -873,6 +898,20 @@ class DeclarativeActionExecutor(Executor):
|
||||
Follows .NET's DefaultTransform pattern - accepts any input type:
|
||||
- dict/Mapping: Used directly as workflow.inputs
|
||||
- str: Converted to {"input": value}
|
||||
- list[Message]: Treated as the agent-facing message contract
|
||||
(e.g. from WorkflowAgent / as_agent()). The prior conversation
|
||||
history is stored in ``Conversation.messages``/
|
||||
``Conversation.history`` and mirrored to
|
||||
``System.conversations.{id}.messages`` so workflows that
|
||||
reference ``=Conversation.messages`` (e.g. InvokeAzureAgent) see
|
||||
assistant turns and other earlier messages, including non-text
|
||||
content. At the start of a turn this history excludes the current
|
||||
user message; that message's text is instead used as the string
|
||||
input (``Inputs.input``) and surfaced via ``System.LastMessage*``
|
||||
for backward compatibility with simple text-only workflows. Agent
|
||||
executors are responsible for appending the current user message
|
||||
to ``Conversation.messages`` immediately before invoking the
|
||||
inner agent.
|
||||
- DeclarativeMessage: Internal message, no initialization needed
|
||||
- Any other type: Converted via str() to {"input": str(value)}
|
||||
|
||||
@@ -888,6 +927,100 @@ class DeclarativeActionExecutor(Executor):
|
||||
if isinstance(trigger, dict):
|
||||
# Structured inputs - use directly
|
||||
state.initialize(trigger) # type: ignore
|
||||
elif isinstance(trigger, list) and all(isinstance(m, Message) for m in trigger): # pyright: ignore[reportUnknownVariableType]
|
||||
# list[Message] (e.g. from WorkflowAgent / as_agent()).
|
||||
messages_list = cast(list[Message], trigger)
|
||||
|
||||
# Detect continuation: if the workflow's shared state already
|
||||
# carries declarative data from a prior turn (because the host
|
||||
# restored a checkpoint and dispatched this run with
|
||||
# reset_context=False), we MUST NOT call state.initialize() -
|
||||
# that would wipe Conversation.messages, Local.*, System.* etc.
|
||||
# Instead, treat the trigger as the new turn's user input only:
|
||||
# update Inputs.input, append the new user message to existing
|
||||
# Conversation history, and refresh System.LastMessage*.
|
||||
#
|
||||
# Continuation = declarative state already exists in the workflow's
|
||||
# shared state (either left over in-memory from a prior turn on
|
||||
# the same instance, or restored from a checkpoint just before
|
||||
# this run). In that case state.initialize() would wipe Local.*,
|
||||
# System.*, Conversation.* etc., destroying the cross-turn
|
||||
# context we're trying to preserve.
|
||||
is_continuation = state.is_initialized()
|
||||
|
||||
# Locate the trailing user message in the trigger.
|
||||
last_user_index = -1
|
||||
for idx in range(len(messages_list) - 1, -1, -1):
|
||||
if str(messages_list[idx].role).lower() == "user":
|
||||
last_user_index = idx
|
||||
break
|
||||
|
||||
if last_user_index >= 0:
|
||||
last_user_msg = messages_list[last_user_index]
|
||||
last_user_text = last_user_msg.text or ""
|
||||
last_user_id = getattr(last_user_msg, "message_id", "") or ""
|
||||
history_messages = messages_list[:last_user_index] + messages_list[last_user_index + 1 :]
|
||||
else:
|
||||
history_messages = list(messages_list)
|
||||
tail = messages_list[-1] if messages_list else None
|
||||
last_user_text = (tail.text or "") if tail is not None else ""
|
||||
last_user_id = getattr(tail, "message_id", "") or "" if tail is not None else ""
|
||||
|
||||
if is_continuation:
|
||||
# Continuation turn: keep prior Conversation.messages intact.
|
||||
# Refresh inputs and surface the new user message via the
|
||||
# System.LastMessage* fields. We deliberately do NOT append
|
||||
# the new user message to Conversation.messages here: agent
|
||||
# executors append the live user input themselves before
|
||||
# invoking the inner agent (matching the first-turn
|
||||
# contract where Conversation.messages holds prior turns
|
||||
# only).
|
||||
#
|
||||
# Note: ``state.set("Inputs.input", ...)`` would route to
|
||||
# the Custom namespace (Inputs is not a recognized top-level
|
||||
# writable namespace - see DeclarativeWorkflowState.set).
|
||||
# PowerFx expressions like ``=Workflow.Inputs.input`` /
|
||||
# ``=inputs.input`` read state_data["Inputs"] directly, so
|
||||
# we update that dict in place via get_state_data /
|
||||
# set_state_data.
|
||||
state_data = state.get_state_data()
|
||||
inputs_dict = state_data.get("Inputs")
|
||||
if not isinstance(inputs_dict, dict):
|
||||
inputs_dict = {}
|
||||
state_data["Inputs"] = inputs_dict
|
||||
inputs_dict["input"] = last_user_text
|
||||
state.set_state_data(state_data)
|
||||
# Trailing non-user messages (e.g. tool results) sandwiched
|
||||
# before the new user message in the trigger are still
|
||||
# appended so later actions see them.
|
||||
for msg in history_messages:
|
||||
state.append("Conversation.messages", msg)
|
||||
state.append("Conversation.history", msg)
|
||||
conversation_id = state.get("System.ConversationId")
|
||||
if conversation_id:
|
||||
conv_path = f"System.conversations.{conversation_id}.messages"
|
||||
for msg in history_messages:
|
||||
state.append(conv_path, msg)
|
||||
state.set("System.LastMessage", {"Text": last_user_text, "Id": last_user_id})
|
||||
state.set("System.LastMessageText", last_user_text)
|
||||
state.set("System.LastMessageId", last_user_id)
|
||||
else:
|
||||
# First turn: full initialization.
|
||||
state.initialize({"input": last_user_text})
|
||||
|
||||
for msg in history_messages:
|
||||
state.append("Conversation.messages", msg)
|
||||
state.append("Conversation.history", msg)
|
||||
|
||||
conversation_id = state.get("System.ConversationId")
|
||||
if conversation_id:
|
||||
conv_path = f"System.conversations.{conversation_id}.messages"
|
||||
for msg in history_messages:
|
||||
state.append(conv_path, msg)
|
||||
|
||||
state.set("System.LastMessage", {"Text": last_user_text, "Id": last_user_id})
|
||||
state.set("System.LastMessageText", last_user_text)
|
||||
state.set("System.LastMessageId", last_user_id)
|
||||
elif isinstance(trigger, str):
|
||||
# String input - wrap in dict and populate System.LastMessage.Text
|
||||
# so YAML expressions like =System.LastMessage.Text see the user input
|
||||
@@ -895,10 +1028,11 @@ class DeclarativeActionExecutor(Executor):
|
||||
state.set("System.LastMessage", {"Text": trigger, "Id": ""})
|
||||
state.set("System.LastMessageText", trigger)
|
||||
elif not isinstance(
|
||||
trigger, (ActionTrigger, ActionComplete, ConditionResult, LoopIterationResult, LoopControl)
|
||||
trigger,
|
||||
(ActionTrigger, ActionComplete, ConditionResult, LoopIterationResult, LoopControl), # pyright: ignore[reportUnknownArgumentType]
|
||||
):
|
||||
# Any other type - convert to string like .NET's DefaultTransform
|
||||
input_str = str(trigger)
|
||||
input_str = str(cast(Any, trigger))
|
||||
state.initialize({"input": input_str})
|
||||
state.set("System.LastMessage", {"Text": input_str, "Id": ""})
|
||||
state.set("System.LastMessageText", input_str)
|
||||
|
||||
+8
-1
@@ -17,6 +17,7 @@ The key insight is that control flow becomes GRAPH STRUCTURE, not executor logic
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
Message,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
@@ -492,7 +493,13 @@ class JoinExecutor(DeclarativeActionExecutor):
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: dict[str, Any] | str | ActionTrigger | ActionComplete | ConditionResult | LoopIterationResult,
|
||||
trigger: dict[str, Any]
|
||||
| str
|
||||
| list[Message]
|
||||
| ActionTrigger
|
||||
| ActionComplete
|
||||
| ConditionResult
|
||||
| LoopIterationResult,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Simply pass through to continue the workflow."""
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Regression tests for ``_make_powerfx_safe``.
|
||||
|
||||
PowerFx (via pythonnet) only accepts plain primitives, dicts, and lists.
|
||||
``Enum`` instances - especially ``str``- and ``int``-subclass enums like
|
||||
MAF's ``MessageRole`` - silently pass ``isinstance(v, str)`` /
|
||||
``isinstance(v, int)`` checks but blow up later inside pythonnet with
|
||||
``'<EnumName>' value cannot be converted to System.<X>``. These tests
|
||||
pin down the Enum coercion branch so we don't regress that interop fix.
|
||||
"""
|
||||
|
||||
from enum import Enum, IntEnum
|
||||
|
||||
from agent_framework_declarative._workflows._declarative_base import _make_powerfx_safe
|
||||
|
||||
|
||||
class _StrRole(str, Enum):
|
||||
USER = "user"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class _IntCode(IntEnum):
|
||||
ONE = 1
|
||||
TWO = 2
|
||||
|
||||
|
||||
class _PlainEnum(Enum):
|
||||
X = "x"
|
||||
Y = 42
|
||||
|
||||
|
||||
def test_str_subclass_enum_reduces_to_str():
|
||||
assert _make_powerfx_safe(_StrRole.USER) == "user"
|
||||
assert type(_make_powerfx_safe(_StrRole.USER)) is str
|
||||
|
||||
|
||||
def test_int_subclass_enum_reduces_to_int():
|
||||
assert _make_powerfx_safe(_IntCode.ONE) == 1
|
||||
assert type(_make_powerfx_safe(_IntCode.ONE)) is int
|
||||
|
||||
|
||||
def test_plain_enum_reduces_to_underlying_value():
|
||||
assert _make_powerfx_safe(_PlainEnum.X) == "x"
|
||||
assert _make_powerfx_safe(_PlainEnum.Y) == 42
|
||||
|
||||
|
||||
def test_enum_inside_dict_is_coerced():
|
||||
safe = _make_powerfx_safe({"role": _StrRole.USER, "code": _IntCode.TWO})
|
||||
assert safe == {"role": "user", "code": 2}
|
||||
assert type(safe["role"]) is str
|
||||
assert type(safe["code"]) is int
|
||||
|
||||
|
||||
def test_enum_inside_list_is_coerced():
|
||||
safe = _make_powerfx_safe([_StrRole.USER, _IntCode.ONE])
|
||||
assert safe == ["user", 1]
|
||||
assert type(safe[0]) is str
|
||||
assert type(safe[1]) is int
|
||||
@@ -228,6 +228,94 @@ actions:
|
||||
outputs = result.get_outputs()
|
||||
assert any("hello-world" in str(o) for o in outputs), f"Expected 'hello-world' in outputs but got: {outputs}"
|
||||
|
||||
async def test_as_agent_round_trip_with_last_message_text(self):
|
||||
"""Regression test: a declarative workflow built via WorkflowFactory must be
|
||||
consumable as an AIAgent via Workflow.as_agent().
|
||||
|
||||
Specifically, the declarative start executor must accept list[Message]
|
||||
(the input passed by WorkflowAgent) and populate System.LastMessageText
|
||||
so =System.LastMessageText is resolvable in the YAML.
|
||||
"""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: as-agent-roundtrip-test
|
||||
actions:
|
||||
- kind: SetVariable
|
||||
variable: Local.echo
|
||||
value: =System.LastMessageText
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: =Local.echo
|
||||
""")
|
||||
|
||||
agent = workflow.as_agent(name="echo-agent")
|
||||
response = await agent.run("Hello there")
|
||||
|
||||
assert "Hello there" in response.text, (
|
||||
f"Expected 'Hello there' in agent response text but got: {response.text!r}"
|
||||
)
|
||||
|
||||
async def test_as_agent_continuation_preserves_prior_state(self):
|
||||
"""Regression test for the ``is_continuation`` branch in
|
||||
``DeclarativeWorkflowExecutor._ensure_state_initialized``.
|
||||
|
||||
Verifies, end-to-end via ``Workflow.as_agent()``:
|
||||
* Turn 1 initializes the declarative state via ``state.initialize``.
|
||||
* Turn 2 takes the *continuation* branch (skips ``state.initialize``),
|
||||
so any non-Inputs/non-System state stamped on turn 1 survives.
|
||||
* Turn 2 still refreshes ``Inputs.input`` and
|
||||
``System.LastMessage*`` to the new user message.
|
||||
|
||||
Without state preservation, ``Workflow.run`` would clear shared state
|
||||
on entry and ``state.initialize`` would re-run on every turn,
|
||||
wiping the marker we stamped between calls.
|
||||
"""
|
||||
from agent_framework_declarative._workflows._declarative_base import DECLARATIVE_STATE_KEY
|
||||
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: as-agent-continuation-test
|
||||
actions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: =System.LastMessageText
|
||||
""")
|
||||
|
||||
agent = workflow.as_agent(name="continuation-agent")
|
||||
|
||||
first = await agent.run("turn-1-msg")
|
||||
assert first.text == "turn-1-msg", f"Expected turn-1 echo 'turn-1-msg', got: {first.text!r}"
|
||||
|
||||
# Stamp a marker into the declarative state between turns. The
|
||||
# continuation branch must preserve it; a state-clearing run would
|
||||
# wipe ``DECLARATIVE_STATE_KEY`` and force re-initialization.
|
||||
state_data = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert isinstance(state_data, dict), "Expected declarative state to be initialized after turn 1"
|
||||
state_data["Local"] = {"persisted_marker": "kept-from-turn-1"}
|
||||
workflow._state.set(DECLARATIVE_STATE_KEY, state_data)
|
||||
workflow._state.commit()
|
||||
|
||||
second = await agent.run("turn-2-msg")
|
||||
assert second.text == "turn-2-msg", (
|
||||
f"Expected System.LastMessageText to refresh to 'turn-2-msg', got: {second.text!r}"
|
||||
)
|
||||
|
||||
# The continuation branch in ``_ensure_state_initialized`` must:
|
||||
# 1. preserve the cross-turn marker we stamped above
|
||||
# 2. refresh Inputs.input and System.LastMessage* to the new turn
|
||||
post_state = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert isinstance(post_state, dict), "declarative state vanished between turns"
|
||||
local = post_state.get("Local", {})
|
||||
assert local.get("persisted_marker") == "kept-from-turn-1", (
|
||||
f"Cross-turn marker was wiped (state was reset). post_state Local={local!r}"
|
||||
)
|
||||
assert post_state.get("Inputs", {}).get("input") == "turn-2-msg", (
|
||||
f"Inputs.input not refreshed on turn 2: {post_state.get('Inputs')!r}"
|
||||
)
|
||||
assert post_state.get("System", {}).get("LastMessageText") == "turn-2-msg", (
|
||||
f"System.LastMessageText not refreshed on turn 2: {post_state.get('System')!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowFactoryAgentRegistration:
|
||||
"""Tests for agent registration."""
|
||||
|
||||
@@ -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.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"durabletask>=1.3.0,<2",
|
||||
"durabletask-azuremanaged>=1.3.0,<2",
|
||||
"python-dateutil>=2.8.0,<3",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-openai>=1.1.0,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"agent-framework-openai>=1.2.2,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.1.0,<3.0",
|
||||
]
|
||||
|
||||
@@ -272,50 +272,86 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
if not isinstance(self._agent, WorkflowAgent):
|
||||
raise RuntimeError("Agent is not a workflow agent.")
|
||||
|
||||
# Restore from the latest checkpoint if available, otherwise start with an empty history
|
||||
# Determine the latest checkpoint (if any) so we can resume the
|
||||
# workflow's prior state for this turn. The directory is keyed by
|
||||
# the inbound context id (conversation_id when set, otherwise
|
||||
# previous_response_id). Multi-turn declarative workflows need the
|
||||
# workflow's internal state (e.g. Conversation.messages,
|
||||
# intermediate Local.* variables) to survive across user turns;
|
||||
# the only place that state lives is the workflow checkpoint, so
|
||||
# on every turn we restore the latest checkpoint and feed the new
|
||||
# input back into the start executor as a continuation rather than
|
||||
# a fresh run.
|
||||
latest_checkpoint_id: str | None = None
|
||||
restore_storage: FileCheckpointStorage | None = None
|
||||
if context_id is not None:
|
||||
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
|
||||
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=self._agent.workflow.name)
|
||||
restore_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
|
||||
latest_checkpoint = await restore_storage.get_latest(workflow_name=self._agent.workflow.name)
|
||||
if latest_checkpoint is not None:
|
||||
if not is_streaming_request:
|
||||
_ = await self._agent.run(
|
||||
stream=False,
|
||||
checkpoint_id=latest_checkpoint.checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
)
|
||||
else:
|
||||
# Consume the streaming or the invocation will result in a no-op
|
||||
async for _ in self._agent.run(
|
||||
stream=True,
|
||||
checkpoint_id=latest_checkpoint.checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
):
|
||||
pass
|
||||
latest_checkpoint_id = latest_checkpoint.checkpoint_id
|
||||
|
||||
# Storage that will receive checkpoints written during this turn.
|
||||
# When the caller chains with previous_response_id, the next turn
|
||||
# will reference the current response_id as its previous_response_id,
|
||||
# so new checkpoints must land under the current response_id (or the
|
||||
# conversation_id when set). When conversation_id is set, this
|
||||
# matches restore_storage; when only previous_response_id was
|
||||
# supplied, restore_storage points at the *prior* response's
|
||||
# directory and write_storage points at the *current* response's.
|
||||
write_context_id = context.conversation_id or context.response_id
|
||||
write_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, write_context_id))
|
||||
|
||||
# Multi-turn pattern: when we have a prior checkpoint, restore it
|
||||
# first (drive the workflow back to idle with prior state intact),
|
||||
# then make a separate call that delivers the new user input. This
|
||||
# depends on Workflow.run preserving shared state across calls. The
|
||||
# restore-only call may yield events from any pending in-flight
|
||||
# work in the checkpoint; we consume those internally here so they
|
||||
# don't surface to the response stream as duplicates.
|
||||
#
|
||||
# If the restored checkpoint had pending request_info events, the
|
||||
# restore-only call replays them through
|
||||
# ``WorkflowAgent._convert_workflow_event_to_agent_response_updates``
|
||||
# and populates ``self._agent.pending_requests``. That is the correct
|
||||
# state: those requests are genuinely outstanding, and the next
|
||||
# ``run(input_messages, ...)`` call may contain ``function_call_output``
|
||||
# items (carried as FunctionResult/FunctionApprovalResponse content)
|
||||
# that fulfill them via :meth:`WorkflowAgent._process_pending_requests`.
|
||||
if latest_checkpoint_id is not None:
|
||||
if is_streaming_request:
|
||||
async for _ in self._agent.run(
|
||||
stream=True,
|
||||
checkpoint_id=latest_checkpoint_id,
|
||||
checkpoint_storage=restore_storage,
|
||||
):
|
||||
pass
|
||||
else:
|
||||
await self._agent.run(
|
||||
stream=False,
|
||||
checkpoint_id=latest_checkpoint_id,
|
||||
checkpoint_storage=restore_storage,
|
||||
)
|
||||
|
||||
# Now run the agent with the latest input
|
||||
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
|
||||
|
||||
# Create a new checkpoint storage for this response based on the following rules:
|
||||
# - If no previous response ID or conversation ID is provided,
|
||||
# create a new checkpoint storage for this response
|
||||
# - If a previous response ID is provided, create a new checkpoint storage for this response
|
||||
# - If a conversation ID is provided, reuse the existing checkpoint storage for the conversation
|
||||
context_id = context.conversation_id or context.response_id
|
||||
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
|
||||
|
||||
yield response_event_stream.emit_created()
|
||||
yield response_event_stream.emit_in_progress()
|
||||
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode
|
||||
response = await self._agent.run(input_messages, stream=False, checkpoint_storage=checkpoint_storage)
|
||||
# Run the agent in non-streaming mode with the new user input.
|
||||
response = await self._agent.run(
|
||||
input_messages,
|
||||
stream=False,
|
||||
checkpoint_storage=write_storage,
|
||||
)
|
||||
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
async for item in _to_outputs(response_event_stream, content):
|
||||
yield item
|
||||
|
||||
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
|
||||
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
@@ -323,8 +359,12 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
# lazily created on matching content, closed when a different type arrives.
|
||||
tracker = _OutputItemTracker(response_event_stream)
|
||||
|
||||
# Run the workflow agent in streaming mode
|
||||
async for update in self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage):
|
||||
# Run the workflow agent in streaming mode with the new user input.
|
||||
async for update in self._agent.run(
|
||||
input_messages,
|
||||
stream=True,
|
||||
checkpoint_storage=write_storage,
|
||||
):
|
||||
for content in update.contents:
|
||||
for event in tracker.handle(content):
|
||||
yield event
|
||||
@@ -337,7 +377,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
for event in tracker.close():
|
||||
yield event
|
||||
|
||||
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
|
||||
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
|
||||
yield response_event_stream.emit_completed()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260428"
|
||||
version = "1.0.0a260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,10 +23,10 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"azure-ai-agentserver-core==2.0.0b3",
|
||||
"azure-ai-agentserver-responses==1.0.0b5",
|
||||
"azure-ai-agentserver-invocations==1.0.0b3",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"azure-ai-agentserver-core>=2.0.0b3,<3",
|
||||
"azure-ai-agentserver-responses>=1.0.0b5,<2",
|
||||
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"agent-framework-openai>=1.1.0,<2",
|
||||
"foundry-local-sdk>=0.5.1,<0.5.2",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260428"
|
||||
version = "1.0.0a260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2.0",
|
||||
"agent-framework-core>=1.2.2,<2.0",
|
||||
"google-genai>=1.65.0,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260428"
|
||||
version = "1.0.0a260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"hyperlight-sandbox>=0.3.0,<0.4",
|
||||
"hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
|
||||
"hyperlight-sandbox-python-guest>=0.3.0,<0.4",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"mem0ai>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"ollama>=0.5.3,<0.5.4",
|
||||
]
|
||||
|
||||
|
||||
@@ -241,6 +241,85 @@ OpenAIChatOptionsT = TypeVar(
|
||||
# endregion
|
||||
|
||||
|
||||
# region Helpers
|
||||
|
||||
|
||||
def _annotations_to_output_text(annotations: Sequence[Annotation] | None) -> list[dict[str, Any]]:
|
||||
"""Convert framework `Annotation` objects to Responses API `output_text` annotation dicts.
|
||||
|
||||
Citations from `file_search`, `code_interpreter` file paths, and url citations all collapse
|
||||
to `Annotation(type="citation", ...)` in the framework. The original API form is recovered
|
||||
here so assistant messages roundtrip cleanly through history forwarding.
|
||||
|
||||
Each Responses API annotation dict carries at most one `start_index`/`end_index` pair, so an
|
||||
`Annotation` with multiple `annotated_regions` is fanned out into one entry per region.
|
||||
Regions missing valid integer span bounds are skipped.
|
||||
"""
|
||||
if not annotations:
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for annotation in annotations:
|
||||
if annotation.get("type") != "citation":
|
||||
continue
|
||||
props = annotation.get("additional_properties") or {}
|
||||
regions = annotation.get("annotated_regions") or []
|
||||
file_id = annotation.get("file_id")
|
||||
url = annotation.get("url")
|
||||
title = annotation.get("title")
|
||||
container_id = props.get("container_id")
|
||||
|
||||
if container_id and file_id:
|
||||
for region in regions:
|
||||
start = region.get("start_index")
|
||||
end = region.get("end_index")
|
||||
if not (isinstance(start, int) and isinstance(end, int)):
|
||||
continue
|
||||
entry: dict[str, Any] = {
|
||||
"type": "container_file_citation",
|
||||
"container_id": container_id,
|
||||
"file_id": file_id,
|
||||
"start_index": start,
|
||||
"end_index": end,
|
||||
}
|
||||
if url:
|
||||
entry["filename"] = url
|
||||
out.append(entry)
|
||||
elif url and not file_id and regions:
|
||||
for region in regions:
|
||||
start = region.get("start_index")
|
||||
end = region.get("end_index")
|
||||
if not (isinstance(start, int) and isinstance(end, int)):
|
||||
continue
|
||||
out.append({
|
||||
"type": "url_citation",
|
||||
"url": url,
|
||||
"title": title or "",
|
||||
"start_index": start,
|
||||
"end_index": end,
|
||||
})
|
||||
elif file_id and url:
|
||||
entry = {
|
||||
"type": "file_citation",
|
||||
"file_id": file_id,
|
||||
"filename": url,
|
||||
}
|
||||
if (idx := props.get("index")) is not None:
|
||||
entry["index"] = idx
|
||||
out.append(entry)
|
||||
elif file_id:
|
||||
entry = {
|
||||
"type": "file_path",
|
||||
"file_id": file_id,
|
||||
}
|
||||
if (idx := props.get("index")) is not None:
|
||||
entry["index"] = idx
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region ResponsesClient
|
||||
|
||||
|
||||
@@ -1374,7 +1453,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
return {
|
||||
"type": "output_text",
|
||||
"text": content.text,
|
||||
"annotations": [],
|
||||
"annotations": _annotations_to_output_text(getattr(content, "annotations", None)),
|
||||
}
|
||||
return {
|
||||
"type": "input_text",
|
||||
@@ -1522,6 +1601,13 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
"approve": content.approved,
|
||||
}
|
||||
case "hosted_file":
|
||||
# `input_file` is an input-only content type in the Responses API and is rejected
|
||||
# inside an assistant message. Hosted-file content on an assistant message
|
||||
# represents a citation produced by a hosted tool (e.g., file_search) and cannot be
|
||||
# meaningfully replayed as input — drop it. The accompanying text annotations carry
|
||||
# the citation context for round-tripping.
|
||||
if role == "assistant":
|
||||
return {}
|
||||
return {
|
||||
"type": "input_file",
|
||||
"file_id": content.file_id,
|
||||
@@ -2502,45 +2588,63 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
|
||||
ann_type = _get_ann_value("type")
|
||||
ann_file_id = _get_ann_value("file_id")
|
||||
# Hosted-file citations attach as text annotations (matching the non-streaming path)
|
||||
# so they don't roundtrip as standalone `input_file` items in assistant history.
|
||||
if ann_type == "file_path":
|
||||
if ann_file_id:
|
||||
annotation_obj = Annotation(
|
||||
type="citation",
|
||||
file_id=str(ann_file_id),
|
||||
additional_properties={
|
||||
"annotation_index": event.annotation_index,
|
||||
"index": _get_ann_value("index"),
|
||||
},
|
||||
raw_representation=annotation,
|
||||
)
|
||||
contents.append(
|
||||
Content.from_hosted_file(
|
||||
file_id=str(ann_file_id),
|
||||
additional_properties={
|
||||
"annotation_index": event.annotation_index,
|
||||
"index": _get_ann_value("index"),
|
||||
},
|
||||
raw_representation=event,
|
||||
)
|
||||
Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
|
||||
)
|
||||
elif ann_type == "file_citation":
|
||||
if ann_file_id:
|
||||
ann_filename = _get_ann_value("filename")
|
||||
annotation_obj = Annotation(
|
||||
type="citation",
|
||||
file_id=str(ann_file_id),
|
||||
url=ann_filename,
|
||||
additional_properties={
|
||||
"annotation_index": event.annotation_index,
|
||||
"index": _get_ann_value("index"),
|
||||
},
|
||||
raw_representation=annotation,
|
||||
)
|
||||
contents.append(
|
||||
Content.from_hosted_file(
|
||||
file_id=str(ann_file_id),
|
||||
additional_properties={
|
||||
"annotation_index": event.annotation_index,
|
||||
"filename": _get_ann_value("filename"),
|
||||
"index": _get_ann_value("index"),
|
||||
},
|
||||
raw_representation=event,
|
||||
)
|
||||
Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
|
||||
)
|
||||
elif ann_type == "container_file_citation":
|
||||
if ann_file_id:
|
||||
ann_filename = _get_ann_value("filename")
|
||||
ann_start = _get_ann_value("start_index")
|
||||
ann_end = _get_ann_value("end_index")
|
||||
annotation_obj = Annotation(
|
||||
type="citation",
|
||||
file_id=str(ann_file_id),
|
||||
url=ann_filename,
|
||||
additional_properties={
|
||||
"annotation_index": event.annotation_index,
|
||||
"container_id": _get_ann_value("container_id"),
|
||||
},
|
||||
raw_representation=annotation,
|
||||
)
|
||||
if ann_start is not None and ann_end is not None:
|
||||
annotation_obj["annotated_regions"] = [
|
||||
TextSpanRegion(
|
||||
type="text_span",
|
||||
start_index=ann_start,
|
||||
end_index=ann_end,
|
||||
)
|
||||
]
|
||||
contents.append(
|
||||
Content.from_hosted_file(
|
||||
file_id=str(ann_file_id),
|
||||
additional_properties={
|
||||
"annotation_index": event.annotation_index,
|
||||
"container_id": _get_ann_value("container_id"),
|
||||
"filename": _get_ann_value("filename"),
|
||||
"start_index": _get_ann_value("start_index"),
|
||||
"end_index": _get_ann_value("end_index"),
|
||||
},
|
||||
raw_representation=event,
|
||||
)
|
||||
Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
|
||||
)
|
||||
elif ann_type == "url_citation":
|
||||
ann_url = _get_ann_value("url")
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -1914,6 +1914,285 @@ def test_hosted_file_content_preparation() -> None:
|
||||
assert result["file_id"] == "file_abc123"
|
||||
|
||||
|
||||
def test_assistant_text_preserves_citation_annotations_on_roundtrip() -> None:
|
||||
"""Citation annotations on assistant text should survive serialization back to the Responses API.
|
||||
|
||||
Previously `output_text.annotations` was hardcoded to `[]`, silently dropping `file_search`
|
||||
citation context on every roundtrip. Preserving them keeps citations intact across
|
||||
multi-agent forwarding.
|
||||
"""
|
||||
from agent_framework._types import Annotation, TextSpanRegion
|
||||
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
text_content = Content.from_text(
|
||||
"Per the docs, the answer is X. See also the report.",
|
||||
annotations=[
|
||||
Annotation(
|
||||
type="citation",
|
||||
file_id="file-abc123",
|
||||
url="guidelines.md",
|
||||
additional_properties={"index": 12},
|
||||
),
|
||||
Annotation(
|
||||
type="citation",
|
||||
title="Quarterly Report",
|
||||
url="https://example.com/report",
|
||||
annotated_regions=[TextSpanRegion(type="text_span", start_index=40, end_index=46)],
|
||||
),
|
||||
Annotation(
|
||||
type="citation",
|
||||
file_id="file-container456",
|
||||
url="data.csv",
|
||||
additional_properties={"container_id": "container-789"},
|
||||
annotated_regions=[TextSpanRegion(type="text_span", start_index=0, end_index=3)],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai("assistant", text_content)
|
||||
|
||||
assert result["type"] == "output_text"
|
||||
annotations = result["annotations"]
|
||||
assert len(annotations) == 3
|
||||
|
||||
file_citation = next(a for a in annotations if a["type"] == "file_citation")
|
||||
assert file_citation["file_id"] == "file-abc123"
|
||||
assert file_citation["filename"] == "guidelines.md"
|
||||
assert file_citation["index"] == 12
|
||||
|
||||
url_citation = next(a for a in annotations if a["type"] == "url_citation")
|
||||
assert url_citation["url"] == "https://example.com/report"
|
||||
assert url_citation["title"] == "Quarterly Report"
|
||||
assert url_citation["start_index"] == 40
|
||||
assert url_citation["end_index"] == 46
|
||||
|
||||
container = next(a for a in annotations if a["type"] == "container_file_citation")
|
||||
assert container["file_id"] == "file-container456"
|
||||
assert container["container_id"] == "container-789"
|
||||
assert container["filename"] == "data.csv"
|
||||
assert container["start_index"] == 0
|
||||
assert container["end_index"] == 3
|
||||
|
||||
|
||||
def test_assistant_text_preserves_file_path_annotation() -> None:
|
||||
"""A `file_path`-style citation (file_id only, no url) should serialize as `file_path`."""
|
||||
from agent_framework._types import Annotation
|
||||
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
text_content = Content.from_text(
|
||||
"See attached.",
|
||||
annotations=[
|
||||
Annotation(
|
||||
type="citation",
|
||||
file_id="file-only",
|
||||
additional_properties={"index": 42},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai("assistant", text_content)
|
||||
|
||||
assert result["type"] == "output_text"
|
||||
annotations = result["annotations"]
|
||||
assert annotations == [{"type": "file_path", "file_id": "file-only", "index": 42}]
|
||||
|
||||
|
||||
def test_assistant_text_fans_out_multiple_annotated_regions() -> None:
|
||||
"""A url_citation with multiple `annotated_regions` should emit one entry per region.
|
||||
|
||||
The Responses API annotation dict carries one start/end pair, so a framework Annotation
|
||||
with N regions must produce N output annotation entries.
|
||||
"""
|
||||
from agent_framework._types import Annotation, TextSpanRegion
|
||||
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
text_content = Content.from_text(
|
||||
"See report. The report says X. Also report.",
|
||||
annotations=[
|
||||
Annotation(
|
||||
type="citation",
|
||||
title="Report",
|
||||
url="https://example.com/report",
|
||||
annotated_regions=[
|
||||
TextSpanRegion(type="text_span", start_index=4, end_index=10),
|
||||
TextSpanRegion(type="text_span", start_index=16, end_index=22),
|
||||
TextSpanRegion(type="text_span", start_index=36, end_index=42),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai("assistant", text_content)
|
||||
annotations = result["annotations"]
|
||||
assert len(annotations) == 3
|
||||
assert all(a["type"] == "url_citation" for a in annotations)
|
||||
spans = [(a["start_index"], a["end_index"]) for a in annotations]
|
||||
assert spans == [(4, 10), (16, 22), (36, 42)]
|
||||
|
||||
|
||||
def test_assistant_text_skips_regions_with_invalid_span() -> None:
|
||||
"""Regions missing integer start/end bounds are skipped rather than emitted with `None`."""
|
||||
from agent_framework._types import Annotation, TextSpanRegion
|
||||
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
text_content = Content.from_text(
|
||||
"See report.",
|
||||
annotations=[
|
||||
Annotation(
|
||||
type="citation",
|
||||
title="Report",
|
||||
url="https://example.com/report",
|
||||
annotated_regions=[
|
||||
TextSpanRegion(type="text_span"), # type: ignore[typeddict-item]
|
||||
TextSpanRegion(type="text_span", start_index=4, end_index=10),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai("assistant", text_content)
|
||||
annotations = result["annotations"]
|
||||
assert len(annotations) == 1
|
||||
assert annotations[0]["start_index"] == 4
|
||||
assert annotations[0]["end_index"] == 10
|
||||
|
||||
|
||||
def test_assistant_text_without_annotations_emits_empty_list() -> None:
|
||||
"""Plain assistant text should still emit `annotations: []` (Azure validation requires the field)."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
result = client._prepare_content_for_openai("assistant", Content.from_text("hello"))
|
||||
|
||||
assert result["type"] == "output_text"
|
||||
assert result["text"] == "hello"
|
||||
assert result["annotations"] == []
|
||||
|
||||
|
||||
def test_streamed_file_citation_coalesces_onto_surrounding_text() -> None:
|
||||
"""Streamed citation events emit empty-text Content with annotations; `_finalize_response`
|
||||
coalesces consecutive text contents and unions their annotations, so the citation lands on
|
||||
the merged assistant text content (not a stray empty-text entry).
|
||||
|
||||
Without this, span indices in the annotation would reference `text == ""` after roundtrip.
|
||||
"""
|
||||
text_event = MagicMock()
|
||||
text_event.type = "response.output_text.delta"
|
||||
text_event.delta = "Hello world."
|
||||
text_event.item_id = "item_1"
|
||||
text_event.output_index = 0
|
||||
text_event.content_index = 0
|
||||
|
||||
citation_event = MagicMock()
|
||||
citation_event.type = "response.output_text.annotation.added"
|
||||
citation_event.annotation_index = 0
|
||||
citation_event.annotation = {
|
||||
"type": "file_citation",
|
||||
"file_id": "file-abc",
|
||||
"filename": "guidelines.md",
|
||||
"index": 5,
|
||||
}
|
||||
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
update1 = client._parse_chunk_from_openai(text_event, chat_options, function_call_ids)
|
||||
update2 = client._parse_chunk_from_openai(citation_event, chat_options, function_call_ids)
|
||||
|
||||
response = ChatResponse.from_updates([update1, update2])
|
||||
|
||||
assert len(response.messages) == 1
|
||||
contents = response.messages[0].contents
|
||||
assert len(contents) == 1
|
||||
merged = contents[0]
|
||||
assert merged.type == "text"
|
||||
assert merged.text == "Hello world."
|
||||
assert merged.annotations is not None
|
||||
assert len(merged.annotations) == 1
|
||||
assert merged.annotations[0]["file_id"] == "file-abc"
|
||||
|
||||
|
||||
def test_streamed_file_citation_roundtrips_as_assistant_history() -> None:
|
||||
"""End-to-end: file_citation arrives via streaming, then gets forwarded as assistant history.
|
||||
|
||||
Reproduces the user-reported sequential/group-chat workflow bug where one agent's
|
||||
`file_search` citations became `input_file` items in the next agent's request and were
|
||||
rejected by the Responses API.
|
||||
"""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
text_event = MagicMock()
|
||||
text_event.type = "response.output_text.delta"
|
||||
text_event.delta = "According to the docs, the answer is X."
|
||||
text_event.item_id = "item_1"
|
||||
text_event.output_index = 0
|
||||
text_event.content_index = 0
|
||||
|
||||
citation_event = MagicMock()
|
||||
citation_event.type = "response.output_text.annotation.added"
|
||||
citation_event.annotation_index = 0
|
||||
citation_event.annotation = {
|
||||
"type": "file_citation",
|
||||
"file_id": "file-xyz789",
|
||||
"filename": "guidelines.md",
|
||||
"index": 12,
|
||||
}
|
||||
|
||||
update1 = client._parse_chunk_from_openai(text_event, chat_options, function_call_ids)
|
||||
update2 = client._parse_chunk_from_openai(citation_event, chat_options, function_call_ids)
|
||||
|
||||
assistant_history = Message(
|
||||
role="assistant",
|
||||
contents=[*update1.contents, *update2.contents],
|
||||
)
|
||||
prepared = client._prepare_message_for_openai(assistant_history)
|
||||
|
||||
assert len(prepared) == 1
|
||||
content_items = prepared[0].get("content", [])
|
||||
types = [c.get("type") for c in content_items]
|
||||
assert "input_file" not in types, f"input_file leaked into assistant history: {types}"
|
||||
output_text_items = [c for c in content_items if c.get("type") == "output_text"]
|
||||
assert any(
|
||||
any(a.get("type") == "file_citation" and a.get("file_id") == "file-xyz789" for a in c.get("annotations", []))
|
||||
for c in output_text_items
|
||||
), "file_citation annotation should survive the streaming → history roundtrip"
|
||||
|
||||
|
||||
def test_hosted_file_in_assistant_message_does_not_emit_input_file() -> None:
|
||||
"""Hosted file citations attached to an assistant message must not roundtrip as `input_file`.
|
||||
|
||||
The Responses API rejects `input_file` items inside an assistant role's content array;
|
||||
`input_file` is an input-only content type. This guards the multi-agent / sequential workflow
|
||||
case where one agent's `file_search` citations get forwarded as history to the next call.
|
||||
"""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
assistant_msg = Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_text("According to the docs, the answer is X."),
|
||||
Content.from_hosted_file(file_id="file_abc123"),
|
||||
],
|
||||
)
|
||||
|
||||
prepared = client._prepare_message_for_openai(assistant_msg)
|
||||
|
||||
assert len(prepared) == 1
|
||||
assistant_item = prepared[0]
|
||||
assert assistant_item["role"] == "assistant"
|
||||
content_types = [c.get("type") for c in assistant_item.get("content", [])]
|
||||
assert "input_file" not in content_types, (
|
||||
f"`input_file` is not valid inside an assistant message; got {content_types}"
|
||||
)
|
||||
assert "output_text" in content_types
|
||||
|
||||
|
||||
def test_function_approval_response_with_mcp_tool_call() -> None:
|
||||
"""Test function approval response content with MCP server tool call content."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
@@ -2682,7 +2961,7 @@ def test_streaming_response_in_progress_type() -> None:
|
||||
|
||||
|
||||
def test_streaming_annotation_added_with_file_path() -> None:
|
||||
"""Test streaming annotation added event with file_path type extracts HostedFileContent."""
|
||||
"""Streaming `file_path` should attach as a text annotation, matching non-streaming."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
@@ -2700,15 +2979,23 @@ def test_streaming_annotation_added_with_file_path() -> None:
|
||||
|
||||
assert len(response.contents) == 1
|
||||
content = response.contents[0]
|
||||
assert content.type == "hosted_file"
|
||||
assert content.file_id == "file-abc123"
|
||||
assert content.additional_properties is not None
|
||||
assert content.additional_properties.get("annotation_index") == 0
|
||||
assert content.additional_properties.get("index") == 42
|
||||
assert content.type == "text"
|
||||
assert content.annotations is not None
|
||||
assert len(content.annotations) == 1
|
||||
annotation = content.annotations[0]
|
||||
assert annotation["type"] == "citation"
|
||||
assert annotation["file_id"] == "file-abc123"
|
||||
assert annotation["additional_properties"]["annotation_index"] == 0
|
||||
assert annotation["additional_properties"]["index"] == 42
|
||||
|
||||
|
||||
def test_streaming_annotation_added_with_file_citation() -> None:
|
||||
"""Test streaming annotation added event with file_citation type extracts HostedFileContent."""
|
||||
"""Streaming `file_citation` should attach as a text annotation, matching non-streaming.
|
||||
|
||||
Previously the streaming path produced a standalone `HostedFileContent`, which then
|
||||
serialized as `input_file` in assistant history and was rejected by the Responses API.
|
||||
Annotations on text content roundtrip cleanly.
|
||||
"""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
@@ -2727,15 +3014,19 @@ def test_streaming_annotation_added_with_file_citation() -> None:
|
||||
|
||||
assert len(response.contents) == 1
|
||||
content = response.contents[0]
|
||||
assert content.type == "hosted_file"
|
||||
assert content.file_id == "file-xyz789"
|
||||
assert content.additional_properties is not None
|
||||
assert content.additional_properties.get("filename") == "sample.txt"
|
||||
assert content.additional_properties.get("index") == 15
|
||||
assert content.type == "text"
|
||||
assert content.annotations is not None
|
||||
assert len(content.annotations) == 1
|
||||
annotation = content.annotations[0]
|
||||
assert annotation["type"] == "citation"
|
||||
assert annotation["file_id"] == "file-xyz789"
|
||||
assert annotation["url"] == "sample.txt"
|
||||
assert annotation["additional_properties"]["annotation_index"] == 1
|
||||
assert annotation["additional_properties"]["index"] == 15
|
||||
|
||||
|
||||
def test_streaming_annotation_added_with_container_file_citation() -> None:
|
||||
"""Test streaming annotation added event with container_file_citation type."""
|
||||
"""Streaming `container_file_citation` should attach as a text annotation."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
@@ -2756,13 +3047,19 @@ def test_streaming_annotation_added_with_container_file_citation() -> None:
|
||||
|
||||
assert len(response.contents) == 1
|
||||
content = response.contents[0]
|
||||
assert content.type == "hosted_file"
|
||||
assert content.file_id == "file-container123"
|
||||
assert content.additional_properties is not None
|
||||
assert content.additional_properties.get("container_id") == "container-456"
|
||||
assert content.additional_properties.get("filename") == "data.csv"
|
||||
assert content.additional_properties.get("start_index") == 10
|
||||
assert content.additional_properties.get("end_index") == 50
|
||||
assert content.type == "text"
|
||||
assert content.annotations is not None
|
||||
assert len(content.annotations) == 1
|
||||
annotation = content.annotations[0]
|
||||
assert annotation["type"] == "citation"
|
||||
assert annotation["file_id"] == "file-container123"
|
||||
assert annotation["url"] == "data.csv"
|
||||
assert annotation["additional_properties"]["container_id"] == "container-456"
|
||||
assert annotation["annotated_regions"] is not None
|
||||
assert len(annotation["annotated_regions"]) == 1
|
||||
region = annotation["annotated_regions"][0]
|
||||
assert region["start_index"] == 10
|
||||
assert region["end_index"] == 50
|
||||
|
||||
|
||||
def test_streaming_annotation_added_with_url_citation() -> None:
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"azure-core>=1.30.0,<2",
|
||||
"httpx>=0.27.0,<0.29",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260428"
|
||||
version = "1.0.0b260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.1,<2",
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"redis>=6.4.0,<7.2.1",
|
||||
"redisvl>=0.11.0,<0.16",
|
||||
"numpy>=2.2.6,<3"
|
||||
|
||||
@@ -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.2.1"
|
||||
version = "1.2.2"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core[all]==1.2.1",
|
||||
"agent-framework-core[all]==1.2.2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -52,8 +52,9 @@ dev = [
|
||||
[tool.uv]
|
||||
package = false
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
# Keep transitive litellm below the compromised 1.82.7/1.82.8 releases.
|
||||
constraint-dependencies = ["litellm<1.82.7"]
|
||||
# Security floors for transitive deps; overrides bypass litellm[proxy]'s strict pins.
|
||||
constraint-dependencies = ["litellm>=1.83.7", "fastapi-sso>=0.19.0"]
|
||||
override-dependencies = ["mcp[ws]>=1.27.0", "uvicorn[standard]>=0.34.0"]
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
@@ -93,7 +94,6 @@ agent-framework-orchestrations = { workspace = true }
|
||||
agent-framework-purview = { workspace = true }
|
||||
agent-framework-redis = { workspace = true }
|
||||
agent-framework-azure-contentunderstanding = { workspace = true }
|
||||
litellm = { url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl" }
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
|
||||
+13
-26
@@ -1199,6 +1199,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
@@ -1345,19 +1358,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1464,19 +1464,6 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+316
-346
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user