Merge branch 'main' into feature/python-foundry-hosted-agent-vnext

This commit is contained in:
Tao Chen
2026-04-14 10:32:14 -07:00
48 changed files with 5407 additions and 337 deletions
@@ -486,8 +486,8 @@ YAML_KV_RE = re.compile(
)
# Validates skill names: lowercase letters, numbers, hyphens only;
# must not start or end with a hyphen.
VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$")
# must not start or end with a hyphen, and must not contain consecutive hyphens.
VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$")
# Default system prompt template for advertising available skills to the model.
# Use {skills} as the placeholder for the generated skills XML list.
@@ -1156,7 +1156,8 @@ def _validate_skill_metadata(
if len(name) > MAX_NAME_LENGTH or not VALID_NAME_RE.match(name):
return (
f"Skill from '{source}' has an invalid name '{name}': Must be {MAX_NAME_LENGTH} characters or fewer, "
"using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen."
"using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen "
"or contain consecutive hyphens."
)
if not description or not description.strip():
@@ -1241,6 +1242,17 @@ def _read_and_parse_skill_file(
return None
name, description = result
dir_name = Path(skill_dir_path).name
if name != dir_name:
logger.error(
"SKILL.md at '%s' has frontmatter name '%s' that does not match the directory name '%s'; skipping.",
skill_file,
name,
dir_name,
)
return None
return name, description, content
@@ -2816,6 +2816,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
cleanup_hooks if cleanup_hooks is not None else []
)
self._cleanup_run: bool = False
self._stream_error: Exception | None = None
self._inner_stream: ResponseStream[Any, Any] | None = None
self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None
self._wrap_inner: bool = False
@@ -2948,8 +2949,12 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
await self._run_cleanup_hooks()
await self.get_final_response()
raise
except Exception:
await self._run_cleanup_hooks()
except Exception as exc:
self._stream_error = exc
try:
await self._run_cleanup_hooks()
finally:
self._stream_error = None
raise
if self._map_update is not None:
update = self._map_update(update) # type: ignore[assignment]
@@ -119,15 +119,11 @@ class WorkflowAgent(BaseAgent):
if not any(is_type_compatible(list[Message], input_type) for input_type in start_executor.input_types):
raise ValueError("Workflow's start executor cannot handle list[Message]")
resolved_context_providers = list(context_providers) if context_providers is not None else []
if not resolved_context_providers:
resolved_context_providers.append(InMemoryHistoryProvider())
super().__init__(
id=id,
name=name,
description=description,
context_providers=resolved_context_providers,
context_providers=context_providers,
**kwargs,
)
self._workflow: Workflow = workflow
@@ -261,6 +257,15 @@ class WorkflowAgent(BaseAgent):
An AgentResponse representing the workflow execution results.
"""
input_messages = normalize_messages_input(messages)
if (
not any(
provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider)
)
and session is not None
):
self.context_providers.append(InMemoryHistoryProvider())
provider_session = session
if provider_session is None and self.context_providers:
provider_session = AgentSession()
@@ -332,6 +337,15 @@ class WorkflowAgent(BaseAgent):
AgentResponseUpdate objects representing the workflow execution progress.
"""
input_messages = normalize_messages_input(messages)
if (
not any(
provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider)
)
and session is not None
):
self.context_providers.append(InMemoryHistoryProvider())
provider_session = session
if provider_session is None and self.context_providers:
provider_session = AgentSession()
@@ -7,10 +7,13 @@ This module lazily re-exports objects from:
Supported classes and functions:
- AgentFrameworkAgent
- AgentFrameworkWorkflow
- AGUIChatClient
- AGUIEventConverter
- AGUIHttpService
- add_agent_framework_fastapi_endpoint
- state_update
- __version__
"""
import importlib
@@ -23,6 +26,10 @@ _IMPORTS = [
"AgentFrameworkWorkflow",
"add_agent_framework_fastapi_endpoint",
"AGUIChatClient",
"AGUIEventConverter",
"AGUIHttpService",
"state_update",
"__version__",
]
@@ -8,6 +8,7 @@ from agent_framework_ag_ui import (
AGUIHttpService,
__version__,
add_agent_framework_fastapi_endpoint,
state_update,
)
__all__ = [
@@ -18,4 +19,5 @@ __all__ = [
"AgentFrameworkWorkflow",
"__version__",
"add_agent_framework_fastapi_endpoint",
"state_update",
]
@@ -1323,6 +1323,12 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
from ._types import ChatResponse
try:
if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage]
# Stream errored; skip get_final_response() to avoid firing
# result hooks such as after_run context providers on error
# paths. Capture the error on the span before returning.
capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage]
return
response: ChatResponse[Any] = await result_stream.get_final_response()
duration = duration_state.get("duration")
response_attributes = _get_response_attributes(attributes, response)
@@ -1579,6 +1585,12 @@ class AgentTelemetryLayer:
from ._types import AgentResponse
try:
if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage]
# Stream errored; skip get_final_response() to avoid firing
# result hooks such as after_run context providers on error
# paths. Capture the error on the span before returning.
capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage]
return
response: AgentResponse[Any] = await result_stream.get_final_response()
duration = duration_state.get("duration")
response_attributes = _get_response_attributes(
+3 -3
View File
@@ -34,14 +34,13 @@ all = [
"mcp>=1.24.0,<2",
"agent-framework-a2a",
"agent-framework-ag-ui",
"agent-framework-anthropic",
"agent-framework-azure-ai-search",
"agent-framework-azure-cosmos",
"agent-framework-anthropic",
"agent-framework-openai",
"agent-framework-claude",
"agent-framework-azurefunctions",
"agent-framework-bedrock",
"agent-framework-chatkit",
"agent-framework-claude",
"agent-framework-copilotstudio",
"agent-framework-declarative",
"agent-framework-devui",
@@ -52,6 +51,7 @@ all = [
"agent-framework-lab",
"agent-framework-mem0",
"agent-framework-ollama",
"agent-framework-openai",
"agent-framework-orchestrations",
"agent-framework-purview",
"agent-framework-redis",
@@ -296,6 +296,15 @@ class TestDiscoverAndLoadSkills:
skills = _discover_file_skills([str(tmp_path)])
assert len(skills) == 0
def test_skips_skill_with_name_directory_mismatch(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "wrong-dir-name"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8"
)
skills = _discover_file_skills([str(tmp_path)])
assert len(skills) == 0
def test_deduplicates_skill_names(self, tmp_path: Path) -> None:
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
@@ -904,6 +913,11 @@ class TestSkill:
provider = SkillsProvider(skills=[invalid_skill])
assert len(provider._skills) == 0
def test_name_with_consecutive_hyphens_skipped(self) -> None:
invalid_skill = Skill(name="consecutive--hyphens", description="A skill.", content="Body")
provider = SkillsProvider(skills=[invalid_skill])
assert len(provider._skills) == 0
def test_name_too_long_skipped(self) -> None:
invalid_skill = Skill(name="a" * 65, description="A skill.", content="Body")
provider = SkillsProvider(skills=[invalid_skill])
@@ -1421,6 +1435,11 @@ class TestValidateSkillMetadata:
assert result is not None
assert "invalid name" in result
def test_name_with_consecutive_hyphens(self) -> None:
result = _validate_skill_metadata("consecutive--hyphens", "desc", "source")
assert result is not None
assert "invalid name" in result
def test_single_char_name(self) -> None:
assert _validate_skill_metadata("a", "desc", "source") is None
@@ -1526,6 +1545,15 @@ class TestReadAndParseSkillFile:
result = _read_and_parse_skill_file(str(skill_dir))
assert result is None
def test_name_directory_mismatch_returns_none(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "wrong-dir-name"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8"
)
result = _read_and_parse_skill_file(str(skill_dir))
assert result is None
# ---------------------------------------------------------------------------
# Tests: _create_resource_element
@@ -14,6 +14,7 @@ from agent_framework import (
AgentSession,
Content,
Executor,
HistoryProvider,
InMemoryHistoryProvider,
Message,
ResponseStream,
@@ -678,6 +679,110 @@ class TestWorkflowAgent:
assert agent.context_providers == [explicit_provider]
async def test_no_history_provider_injected_when_session_is_none(self) -> None:
"""Test that InMemoryHistoryProvider is NOT injected when session is None."""
capturing_executor = ConversationHistoryCapturingExecutor(id="no_session_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="No Session Agent")
await agent.run("hello")
assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
async def test_no_history_provider_injected_when_session_is_none_streaming(self) -> None:
"""Test that InMemoryHistoryProvider is NOT injected when session is None (streaming)."""
capturing_executor = ConversationHistoryCapturingExecutor(id="no_session_stream_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="No Session Stream Agent")
async for _ in agent.run("hello", stream=True):
pass
assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
async def test_no_injection_when_history_provider_with_load_messages_exists(self) -> None:
"""Test that no InMemoryHistoryProvider is injected when an existing HistoryProvider has load_messages=True."""
capturing_executor = ConversationHistoryCapturingExecutor(id="existing_provider_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
existing_provider = InMemoryHistoryProvider("custom", load_messages=True)
agent = WorkflowAgent(
workflow=workflow,
name="Existing Provider Agent",
context_providers=[existing_provider],
)
session = AgentSession()
await agent.run("hello", session=session)
# Should still have only the original provider
history_providers = [p for p in agent.context_providers if isinstance(p, HistoryProvider)]
assert len(history_providers) == 1
assert history_providers[0] is existing_provider
async def test_injection_when_history_provider_with_load_messages_false(self) -> None:
"""Test that InMemoryHistoryProvider IS injected when existing HistoryProvider has load_messages=False."""
capturing_executor = ConversationHistoryCapturingExecutor(id="no_load_provider_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
audit_provider = InMemoryHistoryProvider("audit", load_messages=False)
agent = WorkflowAgent(
workflow=workflow,
name="Audit Provider Agent",
context_providers=[audit_provider],
)
session = AgentSession()
await agent.run("hello", session=session)
# Should have injected an additional InMemoryHistoryProvider with load_messages=True
history_providers = [p for p in agent.context_providers if isinstance(p, HistoryProvider)]
assert len(history_providers) == 2
loading_providers = [p for p in history_providers if p.load_messages]
assert len(loading_providers) == 1
assert isinstance(loading_providers[0], InMemoryHistoryProvider)
async def test_no_duplicate_injection_on_multiple_runs(self) -> None:
"""Test that calling run() multiple times does not keep adding InMemoryHistoryProvider."""
capturing_executor = ConversationHistoryCapturingExecutor(id="no_dup_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="No Dup Agent")
session = AgentSession()
await agent.run("first", session=session)
await agent.run("second", session=session)
await agent.run("third", session=session)
history_providers = [p for p in agent.context_providers if isinstance(p, InMemoryHistoryProvider)]
assert len(history_providers) == 1
async def test_no_duplicate_injection_on_multiple_runs_streaming(self) -> None:
"""Test that calling run(stream=True) multiple times does not keep adding InMemoryHistoryProvider."""
capturing_executor = ConversationHistoryCapturingExecutor(id="no_dup_stream_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="No Dup Stream Agent")
session = AgentSession()
async for _ in agent.run("first", stream=True, session=session):
pass
async for _ in agent.run("second", stream=True, session=session):
pass
async for _ in agent.run("third", stream=True, session=session):
pass
history_providers = [p for p in agent.context_providers if isinstance(p, InMemoryHistoryProvider)]
assert len(history_providers) == 1
async def test_injection_with_session_in_streaming_mode(self) -> None:
"""Test that InMemoryHistoryProvider is injected when session is provided in streaming mode."""
capturing_executor = ConversationHistoryCapturingExecutor(id="stream_inject_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="Stream Inject Agent")
session = AgentSession()
async for _ in agent.run("hello", stream=True, session=session):
pass
assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
async def test_checkpoint_storage_passed_to_workflow(self) -> None:
"""Test that checkpoint_storage parameter is passed through to the workflow."""
from agent_framework import InMemoryCheckpointStorage