Merge branch 'main' into feat/durable_task

This commit is contained in:
Shyju Krishnankutty
2026-03-09 12:01:42 -07:00
committed by GitHub
Unverified
35 changed files with 644 additions and 1307 deletions
+18
View File
@@ -8,6 +8,10 @@ inputs:
os:
description: The operating system to set up
required: true
exclude-packages:
description: Space-separated list of packages to exclude from uv sync
required: false
default: ''
runs:
using: "composite"
@@ -19,6 +23,20 @@ runs:
enable-cache: true
cache-suffix: ${{ inputs.os }}-${{ inputs.python-version }}
cache-dependency-glob: "**/uv.lock"
- name: Exclude incompatible workspace packages
if: ${{ inputs.exclude-packages != '' }}
shell: bash
run: |
for pkg in ${{ inputs.exclude-packages }}; do
for f in python/packages/*/pyproject.toml; do
if grep -q "name = \"$pkg\"" "$f"; then
pkg_dir=$(dirname "$f" | sed 's|python/||')
echo "Excluding workspace package: $pkg ($pkg_dir)"
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
fi
done
done
- name: Install the project
shell: bash
run: |
+4 -4
View File
@@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10"]
python-version: ["3.11"]
runs-on: ubuntu-latest
continue-on-error: true
defaults:
@@ -55,7 +55,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10"]
python-version: ["3.11"]
runs-on: ubuntu-latest
continue-on-error: true
defaults:
@@ -84,7 +84,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10"]
python-version: ["3.11"]
runs-on: ubuntu-latest
continue-on-error: true
defaults:
@@ -117,7 +117,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10"]
python-version: ["3.11"]
runs-on: ubuntu-latest
continue-on-error: true
defaults:
@@ -170,7 +170,7 @@ jobs:
environment: integration
timeout-minutes: 60
env:
UV_PYTHON: "3.10"
UV_PYTHON: "3.11"
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
+1
View File
@@ -67,6 +67,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
os: ${{ runner.os }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
+1 -1
View File
@@ -288,7 +288,7 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
UV_PYTHON: "3.10"
UV_PYTHON: "3.11"
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
run:
working-directory: python
env:
UV_PYTHON: "3.10"
UV_PYTHON: "3.11"
steps:
- uses: actions/checkout@v6
# Save the PR number to a file since the workflow_run event
+2 -1
View File
@@ -34,12 +34,13 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
os: ${{ runner.os }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
# Unit tests
- name: Run all tests
run: uv run poe all-tests
run: uv run poe all-tests ${{ matrix.python-version == '3.10' && '--ignore-glob=packages/github_copilot/**' || '' }}
working-directory: ./python
# Surface failing tests
+7
View File
@@ -20,6 +20,13 @@ When making changes to a package, check if the following need updates:
- The package's `AGENTS.md` file (adding/removing/renaming public APIs, architecture changes, import path changes)
- The agent skills in `.github/skills/` if conventions, commands, or workflows change
## Pull Request Description Guidance
When preparing a PR description:
- Follow the repository PR template at `.github/pull_request_template.md` and keep its structure/headings.
- Describe the net change relative to `main` (this is implied; do not call it out explicitly as "vs main").
- Do not add ad-hoc validation sections (for example, "Validation" or "Tests run"); CI/CD and the template checklist cover validation status.
## Quick Reference
Run `uv run poe` from the `python/` directory to see available commands. See [DEV_SETUP.md](DEV_SETUP.md) for detailed usage.
@@ -37,9 +37,8 @@ from agent_framework.openai._responses_client import RawOpenAIResponsesClient
from azure.ai.projects.aio import AIProjectClient
from azure.ai.projects.models import (
ApproximateLocation,
CodeInterpreterContainerAuto,
AutoCodeInterpreterToolParam,
CodeInterpreterTool,
FoundryFeaturesOptInKeys,
ImageGenTool,
MCPTool,
PromptAgentDefinition,
@@ -66,7 +65,6 @@ if sys.version_info >= (3, 11):
else:
from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover
logger = logging.getLogger("agent_framework.azure")
@@ -79,9 +77,6 @@ class AzureAIProjectAgentOptions(OpenAIResponsesOptions, total=False):
reasoning: Reasoning # type: ignore[misc]
"""Configuration for enabling reasoning capabilities (requires azure.ai.projects.models.Reasoning)."""
foundry_features: FoundryFeaturesOptInKeys | str
"""Optional Foundry preview feature opt-in for agent version creation."""
AzureAIClientOptionsT = TypeVar(
"AzureAIClientOptionsT",
@@ -123,6 +118,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
model_deployment_name: str | None = None,
credential: AzureCredentialTypes | None = None,
use_latest_version: bool | None = None,
allow_preview: bool | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
@@ -148,6 +144,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
AsyncTokenCredential, or a callable token provider.
use_latest_version: Boolean flag that indicates whether to use latest agent version
if it exists in the service.
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
kwargs: Additional keyword arguments passed to the parent class.
@@ -208,11 +205,14 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
# Use provided credential
if not credential:
raise ValueError("Azure credential is required when project_client is not provided.")
project_client = AIProjectClient(
endpoint=resolved_endpoint,
credential=credential, # type: ignore[arg-type]
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
should_close_client = True
# Initialize parent
@@ -413,8 +413,6 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
"definition": PromptAgentDefinition(**args),
"description": self.agent_description,
}
if foundry_features := run_options.get("foundry_features"):
create_version_kwargs["foundry_features"] = foundry_features
created_agent = await self.project_client.agents.create_version(**create_version_kwargs)
@@ -513,7 +511,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
"temperature": ("temperature",),
"top_p": ("top_p",),
"reasoning": ("reasoning",),
"foundry_features": ("foundry_features",),
"allow_preview": ("allow_preview",),
}
for run_keys in agent_level_option_to_run_keys.values():
@@ -939,7 +937,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
if file_ids is None and isinstance(container, dict):
file_ids = container.get("file_ids")
resolved = resolve_file_ids(file_ids)
tool_container = CodeInterpreterContainerAuto(file_ids=resolved)
tool_container = AutoCodeInterpreterToolParam(file_ids=resolved)
return CodeInterpreterTool(container=tool_container, **kwargs)
@staticmethod
@@ -1244,6 +1242,7 @@ class AzureAIClient(
model_deployment_name: str | None = None,
credential: AzureCredentialTypes | None = None,
use_latest_version: bool | None = None,
allow_preview: bool | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
env_file_path: str | None = None,
@@ -1268,6 +1267,7 @@ class AzureAIClient(
or AsyncTokenCredential.
use_latest_version: Boolean flag that indicates whether to use latest agent version
if it exists in the service.
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``
middleware: Optional sequence of chat middlewares to include.
function_invocation_configuration: Optional function invocation configuration.
env_file_path: Path to environment file for loading settings.
@@ -1318,6 +1318,7 @@ class AzureAIClient(
model_deployment_name=model_deployment_name,
credential=credential,
use_latest_version=use_latest_version,
allow_preview=allow_preview,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
env_file_path=env_file_path,
@@ -18,6 +18,7 @@ from agent_framework._sessions import AgentSession, BaseContextProvider, Session
from agent_framework._settings import load_settings
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
from azure.ai.projects.aio import AIProjectClient
from openai.types.responses import ResponseInputItemParam
from ._shared import AzureAISettings
@@ -58,6 +59,7 @@ class FoundryMemoryProvider(BaseContextProvider):
project_client: AIProjectClient | None = None,
project_endpoint: str | None = None,
credential: AzureCredentialTypes | None = None,
allow_preview: bool | None = None,
memory_store_name: str,
scope: str | None = None,
context_prompt: str | None = None,
@@ -74,6 +76,7 @@ class FoundryMemoryProvider(BaseContextProvider):
credential: Azure credential for authentication. Accepts a TokenCredential,
AsyncTokenCredential, or a callable token provider.
Required when project_client is not provided.
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
memory_store_name: The name of the memory store to use.
scope: The namespace that logically groups and isolates memories (e.g., user ID).
If None, `session_id` will be used.
@@ -100,11 +103,14 @@ class FoundryMemoryProvider(BaseContextProvider):
)
if not credential:
raise ValueError("Azure credential is required when project_client is not provided.")
project_client = AIProjectClient(
endpoint=resolved_endpoint,
credential=credential, # type: ignore[arg-type]
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
if not memory_store_name:
raise ValueError("memory_store_name is required")
@@ -169,8 +175,8 @@ class FoundryMemoryProvider(BaseContextProvider):
return
# Convert input messages to memory search item format
items = [
{"type": "text", "text": msg.text}
items: list[ResponseInputItemParam] = [
{"type": "message", "role": "user", "content": msg.text}
for msg in context.input_messages
if msg and msg.text and msg.text.strip()
]
@@ -224,7 +230,7 @@ class FoundryMemoryProvider(BaseContextProvider):
messages_to_store.extend(context.response.messages)
# Filter and convert messages to memory update item format
items: list[dict[str, str]] = []
items: list[ResponseInputItemParam] = []
for message in messages_to_store:
if message.role in {"user", "assistant", "system"} and message.text and message.text.strip():
if message.role == "user":
@@ -102,6 +102,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
project_endpoint: str | None = None,
model: str | None = None,
credential: AzureCredentialTypes | None = None,
allow_preview: bool | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
@@ -117,6 +118,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
credential: Azure credential for authentication. Accepts a TokenCredential,
AsyncTokenCredential, or a callable token provider.
Required when project_client is not provided.
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
@@ -146,11 +148,14 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
if not credential:
raise ValueError("Azure credential is required when project_client is not provided.")
project_client = AIProjectClient(
endpoint=resolved_endpoint,
credential=credential, # type: ignore[arg-type]
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
self._should_close_client = True
self._project_client = project_client
@@ -199,7 +204,6 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
response_format = opts.get("response_format")
rai_config = opts.get("rai_config")
reasoning = opts.get("reasoning")
foundry_features = opts.get("foundry_features")
args: dict[str, Any] = {"model": resolved_model}
@@ -246,8 +250,6 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
"definition": PromptAgentDefinition(**args),
"description": description,
}
if foundry_features:
create_version_kwargs["foundry_features"] = foundry_features
created_agent = await self._project_client.agents.create_version(**create_version_kwargs)
@@ -19,9 +19,9 @@ from azure.ai.agents.models import (
from azure.ai.projects.models import (
CodeInterpreterTool,
MCPTool,
TextResponseFormatConfigurationResponseFormatJsonObject,
TextResponseFormatConfigurationResponseFormatText,
TextResponseFormatJsonObject,
TextResponseFormatJsonSchema,
TextResponseFormatText,
Tool,
WebSearchPreviewTool,
)
@@ -479,11 +479,7 @@ def _prepare_mcp_tool_dict_for_azure_ai(tool_dict: dict[str, Any]) -> MCPTool:
def create_text_format_config(
response_format: type[BaseModel] | Mapping[str, Any],
) -> (
TextResponseFormatJsonSchema
| TextResponseFormatConfigurationResponseFormatJsonObject
| TextResponseFormatConfigurationResponseFormatText
):
) -> TextResponseFormatJsonSchema | TextResponseFormatJsonObject | TextResponseFormatText:
"""Convert response_format into Azure text format configuration."""
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
schema = response_format.model_json_schema()
@@ -513,9 +509,9 @@ def create_text_format_config(
config_kwargs["description"] = format_config["description"]
return TextResponseFormatJsonSchema(**config_kwargs)
if format_type == "json_object":
return TextResponseFormatConfigurationResponseFormatJsonObject()
return TextResponseFormatJsonObject()
if format_type == "text":
return TextResponseFormatConfigurationResponseFormatText()
return TextResponseFormatText()
raise IntegrationInvalidRequestException("response_format must be a Pydantic model or mapping.")
@@ -28,7 +28,7 @@ from agent_framework.openai._responses_client import RawOpenAIResponsesClient
from azure.ai.projects.aio import AIProjectClient
from azure.ai.projects.models import (
ApproximateLocation,
CodeInterpreterContainerAuto,
AutoCodeInterpreterToolParam,
CodeInterpreterTool,
FileSearchTool,
ImageGenTool,
@@ -1296,7 +1296,7 @@ def test_from_azure_ai_tools_mcp() -> None:
def test_from_azure_ai_tools_code_interpreter() -> None:
"""Test from_azure_ai_tools with Code Interpreter tool."""
ci_tool = CodeInterpreterTool(container=CodeInterpreterContainerAuto(file_ids=["file-1"]))
ci_tool = CodeInterpreterTool(container=AutoCodeInterpreterToolParam(file_ids=["file-1"]))
parsed_tools = from_azure_ai_tools([ci_tool])
assert len(parsed_tools) == 1
assert parsed_tools[0]["type"] == "code_interpreter"
@@ -86,6 +86,7 @@ class TestInit:
provider = FoundryMemoryProvider(
project_endpoint="https://test.project.endpoint",
credential=mock_credential, # type: ignore[arg-type]
allow_preview=True,
memory_store_name="test_store",
scope="user_123",
)
@@ -93,6 +94,7 @@ class TestInit:
mock_ai_project_client.assert_called_once_with(
endpoint="https://test.project.endpoint",
credential=mock_credential,
allow_preview=True,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
@@ -112,9 +112,7 @@ class SkillResource:
self._accepts_kwargs: bool = False
if function is not None:
sig = inspect.signature(function)
self._accepts_kwargs = any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
)
self._accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
class Skill:
@@ -1458,7 +1458,7 @@ def _update_conversation_id(
if conversation_id is None:
return
if "chat_options" in kwargs:
kwargs["chat_options"].conversation_id = conversation_id
kwargs["chat_options"]["conversation_id"] = conversation_id
else:
kwargs["conversation_id"] = conversation_id
@@ -73,6 +73,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
async_client: AsyncOpenAI | None = None,
project_client: Any | None = None,
project_endpoint: str | None = None,
allow_preview: bool | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
instruction_role: str | None = None,
@@ -120,6 +121,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
project_endpoint: The Azure AI Foundry project endpoint URL.
When provided with ``credential``, an ``AIProjectClient`` will be created
and used to obtain the OpenAI client. Requires the ``azure-ai-projects`` package.
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
env_file_path: Use the environment settings file as a fallback to using env vars.
env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'.
instruction_role: The role to use for 'instruction' messages, for example, summarization
@@ -189,6 +191,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
project_client=project_client,
project_endpoint=project_endpoint,
credential=credential,
allow_preview=allow_preview,
)
azure_openai_settings = load_settings(
@@ -246,21 +249,9 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
project_client: AIProjectClient | None,
project_endpoint: str | None,
credential: AzureCredentialTypes | AzureTokenProvider | None,
allow_preview: bool | None = None,
) -> AsyncOpenAI:
"""Create an AsyncOpenAI client from an Azure AI Foundry project.
Args:
project_client: An existing AIProjectClient to use.
project_endpoint: The Azure AI Foundry project endpoint URL.
credential: Azure credential for authentication.
Returns:
An AsyncAzureOpenAI client obtained from the project client.
Raises:
ValueError: If required parameters are missing or
the azure-ai-projects package is not installed.
"""
"""Create an AsyncOpenAI client from an Azure AI Foundry project."""
if project_client is not None:
return project_client.get_openai_client()
@@ -268,11 +259,14 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
raise ValueError("Azure AI project endpoint is required when project_client is not provided.")
if not credential:
raise ValueError("Azure credential is required when using project_endpoint without a project_client.")
project_client = AIProjectClient(
endpoint=project_endpoint,
credential=credential, # type: ignore[arg-type]
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
project_client_kwargs: dict[str, Any] = {
"endpoint": project_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
return project_client.get_openai_client()
@override
@@ -327,7 +327,9 @@ class RawOpenAIChatClient( # type: ignore[misc]
messages = prepend_instructions_to_messages(list(messages), instructions, role="system")
# Start with a copy of options
run_options = {k: v for k, v in options.items() if v is not None and k not in {"instructions", "tools"}}
run_options = {
k: v for k, v in options.items() if v is not None and k not in {"instructions", "tools", "conversation_id"}
}
# messages
if messages and "messages" not in run_options:
+2 -2
View File
@@ -34,7 +34,7 @@ dependencies = [
# connectors and functions
"openai>=1.99.0",
"azure-identity>=1,<2",
"azure-ai-projects == 2.0.0b4",
"azure-ai-projects>=2.0.0,<3.0",
"mcp[ws]>=1.24.0,<2",
"packaging>=24.1",
]
@@ -55,7 +55,7 @@ all = [
"agent-framework-devui",
"agent-framework-durabletask",
"agent-framework-foundry-local",
"agent-framework-github-copilot",
"agent-framework-github-copilot; python_version >= '3.11'",
"agent-framework-lab",
"agent-framework-mem0",
"agent-framework-ollama",
@@ -626,6 +626,73 @@ async def test_streaming_with_none_delta(
assert any(msg.contents for msg in results)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_cmc_with_conversation_id(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[Message],
mock_chat_completion_response: ChatCompletion,
) -> None:
"""Test that conversation_id is excluded from the completions create call."""
mock_create.return_value = mock_chat_completion_response
chat_history.append(Message(text="hello world", role="user"))
azure_chat_client = AzureOpenAIChatClient()
await azure_chat_client.get_response(
messages=chat_history,
options={"conversation_id": "12345"},
)
call_kwargs = mock_create.call_args.kwargs
assert "conversation_id" not in call_kwargs
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_cmc_streaming_with_conversation_id(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[Message],
mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk],
) -> None:
"""Test that conversation_id is excluded from the streaming completions create call."""
mock_create.return_value = mock_streaming_chat_completion_response
chat_history.append(Message(text="hello world", role="user"))
azure_chat_client = AzureOpenAIChatClient()
async for _ in azure_chat_client.get_response(
messages=chat_history,
options={"conversation_id": "12345"},
stream=True,
):
pass
call_kwargs = mock_create.call_args.kwargs
assert "conversation_id" not in call_kwargs
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_cmc_agent_with_service_session_id(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
mock_chat_completion_response: ChatCompletion,
) -> None:
"""Test that agent.run() with a session containing service_session_id works correctly."""
mock_create.return_value = mock_chat_completion_response
azure_chat_client = AzureOpenAIChatClient()
agent = azure_chat_client.as_agent(
name="TestAgent",
instructions="You are a helpful assistant.",
)
session = agent.get_session(service_session_id="12345")
response = await agent.run("hello", session=session)
assert response is not None
call_kwargs = mock_create.call_args.kwargs
assert "conversation_id" not in call_kwargs
@tool(approval_mode="never_require")
def get_story_text() -> str:
"""Returns a story about Emily and David."""
@@ -3449,3 +3449,66 @@ async def test_streaming_function_calling_response_includes_reasoning_and_tool_r
reasoning_contents = [c for msg in response.messages for c in msg.contents if c.type == "text_reasoning"]
assert len(reasoning_contents) >= 1
assert reasoning_contents[0].id == "rs_test123"
# region _update_conversation_id unit tests
class TestUpdateConversationId:
"""Tests for _update_conversation_id handling dict chat_options."""
def test_chat_options_as_dict(self):
"""When chat_options is a plain dict, conversation_id should be set via key access."""
from agent_framework._tools import _update_conversation_id
kwargs: dict[str, Any] = {"chat_options": {}}
_update_conversation_id(kwargs, "conv_1")
assert kwargs["chat_options"]["conversation_id"] == "conv_1"
def test_chat_options_as_typed_dict(self):
"""When chat_options is a ChatOptions TypedDict, conversation_id should be set via key access."""
from agent_framework import ChatOptions
from agent_framework._tools import _update_conversation_id
opts: ChatOptions = {"temperature": 0.5}
kwargs: dict[str, Any] = {"chat_options": opts}
_update_conversation_id(kwargs, "conv_2")
assert kwargs["chat_options"]["conversation_id"] == "conv_2"
def test_no_chat_options_falls_back_to_kwargs(self):
"""When chat_options is absent, conversation_id should be set directly on kwargs."""
from agent_framework._tools import _update_conversation_id
kwargs: dict[str, Any] = {}
_update_conversation_id(kwargs, "conv_4")
assert kwargs["conversation_id"] == "conv_4"
def test_none_conversation_id_is_noop(self):
"""When conversation_id is None, kwargs should not be modified."""
from agent_framework._tools import _update_conversation_id
kwargs: dict[str, Any] = {"chat_options": {}}
_update_conversation_id(kwargs, None)
assert "conversation_id" not in kwargs["chat_options"]
assert "conversation_id" not in kwargs
def test_options_dict_also_updated(self):
"""The optional options dict should also receive conversation_id."""
from agent_framework._tools import _update_conversation_id
kwargs: dict[str, Any] = {"chat_options": {}}
options: dict[str, Any] = {}
_update_conversation_id(kwargs, "conv_5", options)
assert kwargs["chat_options"]["conversation_id"] == "conv_5"
assert options["conversation_id"] == "conv_5"
def test_dict_overwrites_existing_conversation_id(self):
"""When a dict already has a conversation_id, it should be overwritten."""
from agent_framework._tools import _update_conversation_id
kwargs: dict[str, Any] = {"chat_options": {"conversation_id": "old_id"}}
_update_conversation_id(kwargs, "new_id")
assert kwargs["chat_options"]["conversation_id"] == "new_id"
# endregion
@@ -1161,6 +1161,21 @@ def test_prepare_options_removes_parallel_tool_calls_when_no_tools(openai_unit_t
assert "parallel_tool_calls" not in prepared_options
def test_prepare_options_excludes_conversation_id(openai_unit_test_env: dict[str, str]) -> None:
"""Test that conversation_id is excluded from prepared options for chat completions."""
client = OpenAIChatClient()
messages = [Message(role="user", text="test")]
options = {"conversation_id": "12345", "temperature": 0.7}
prepared_options = client._prepare_options(messages, options)
# conversation_id is not a valid parameter for AsyncCompletions.create()
assert "conversation_id" not in prepared_options
# Other options should still be present
assert prepared_options["temperature"] == 0.7
async def test_streaming_exception_handling(openai_unit_test_env: dict[str, str]) -> None:
"""Test that streaming errors are properly handled."""
client = OpenAIChatClient()
@@ -544,19 +544,19 @@ class TestFunctionExecutor:
static_wrapped = staticmethod(my_async_func)
# Direct check on descriptor object fails (this is the bug)
assert not asyncio.iscoroutinefunction(static_wrapped)
assert not asyncio.iscoroutinefunction(static_wrapped) # type: ignore[reportDeprecated]
assert isinstance(static_wrapped, staticmethod)
# But unwrapping __func__ reveals the async function
unwrapped = static_wrapped.__func__
assert asyncio.iscoroutinefunction(unwrapped)
assert asyncio.iscoroutinefunction(unwrapped) # type: ignore[reportDeprecated]
# When accessed via class attribute, Python's descriptor protocol
# automatically unwraps it, so it works:
class C:
async_static = static_wrapped
assert asyncio.iscoroutinefunction(C.async_static) # Works via descriptor protocol
assert asyncio.iscoroutinefunction(C.async_static) # type: ignore[reportDeprecated] # Works via descriptor protocol
class TestExecutorExplicitTypes:
@@ -15,11 +15,10 @@ import json
import logging
import uuid
from abc import abstractmethod
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from inspect import isawaitable
from typing import Any, cast
from collections.abc import Callable
from agent_framework import (
Content,
@@ -5,8 +5,8 @@
from __future__ import annotations
import re
from typing import Any, cast
from collections.abc import Callable
from typing import Any, cast
from pydantic import BaseModel, Field, field_validator
@@ -26,12 +26,11 @@ from agent_framework._tools import FunctionTool, ToolTypes
from agent_framework._types import AgentRunInputs, normalize_tools
from agent_framework.exceptions import AgentException
from copilot import CopilotClient, CopilotSession
from copilot.generated.session_events import SessionEvent, SessionEventType
from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType
from copilot.types import (
CopilotClientOptions,
MCPServerConfig,
MessageOptions,
PermissionRequest,
PermissionRequestResult,
ResumeSessionConfig,
SessionConfig,
@@ -529,7 +528,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
"""Convert an FunctionTool to a Copilot SDK tool."""
async def handler(invocation: ToolInvocation) -> ToolResult:
args = invocation.get("arguments", {})
args: dict[str, Any] = invocation.arguments or {}
try:
if ai_func.input_model:
args_instance = ai_func.input_model(**args)
@@ -537,13 +536,13 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
else:
result = await ai_func.invoke(arguments=args)
return ToolResult(
textResultForLlm=str(result),
resultType="success",
text_result_for_llm=str(result),
result_type="success",
)
except Exception as e:
return ToolResult(
textResultForLlm=f"Error: {e}",
resultType="failure",
text_result_for_llm=f"Error: {e}",
result_type="failure",
error=str(e),
)
@@ -3,7 +3,7 @@ name = "agent-framework-github-copilot"
description = "GitHub Copilot integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"
version = "1.0.0b260304"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
@@ -15,7 +15,6 @@ classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
@@ -24,7 +23,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.0.0rc3",
"github-copilot-sdk>=0.1.0",
"github-copilot-sdk>=0.1.32",
]
[tool.uv]
@@ -66,7 +65,7 @@ include = ["agent_framework_github_copilot"]
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
python_version = "3.11"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
@@ -16,6 +16,7 @@ from agent_framework import (
)
from agent_framework.exceptions import AgentException
from copilot.generated.session_events import Data, SessionEvent, SessionEventType
from copilot.types import ToolInvocation, ToolResult
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions
@@ -745,10 +746,11 @@ class TestGitHubCopilotAgentToolConversion:
config = call_args[0][0]
copilot_tool = config["tools"][0]
result = await copilot_tool.handler({"arguments": {"arg": "test"}})
result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"}))
assert result["resultType"] == "success"
assert result["textResultForLlm"] == "Result: test"
assert isinstance(result, ToolResult)
assert result.result_type == "success"
assert result.text_result_for_llm == "Result: test"
async def test_tool_handler_returns_failure_result_on_error(
self,
@@ -770,11 +772,61 @@ class TestGitHubCopilotAgentToolConversion:
config = call_args[0][0]
copilot_tool = config["tools"][0]
result = await copilot_tool.handler({"arguments": {"arg": "test"}})
result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"}))
assert result["resultType"] == "failure"
assert "Something went wrong" in result["textResultForLlm"]
assert "Something went wrong" in result["error"]
assert isinstance(result, ToolResult)
assert result.result_type == "failure"
assert "Something went wrong" in result.text_result_for_llm
assert "Something went wrong" in result.error
async def test_tool_handler_rejects_raw_dict_invocation(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that tool handler raises TypeError when called with a raw dict instead of ToolInvocation."""
def my_tool(arg: str) -> str:
"""A test tool."""
return f"Result: {arg}"
agent = GitHubCopilotAgent(client=mock_client, tools=[my_tool])
await agent.start()
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
copilot_tool = config["tools"][0]
with pytest.raises((TypeError, AttributeError)):
await copilot_tool.handler({"arguments": {"arg": "test"}})
async def test_tool_handler_with_empty_arguments(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that tool handler handles ToolInvocation with empty arguments."""
def no_args_tool() -> str:
"""A tool with no arguments."""
return "no args result"
agent = GitHubCopilotAgent(client=mock_client, tools=[no_args_tool])
await agent.start()
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args[0][0]
copilot_tool = config["tools"][0]
result = await copilot_tool.handler(ToolInvocation(arguments={}))
assert isinstance(result, ToolResult)
assert result.result_type == "success"
assert result.text_result_for_llm == "no args result"
def test_copilot_tool_passthrough(
self,
@@ -784,7 +836,7 @@ class TestGitHubCopilotAgentToolConversion:
from copilot.types import Tool as CopilotTool
async def tool_handler(invocation: Any) -> Any:
return {"textResultForLlm": "result", "resultType": "success"}
return {"text_result_for_llm": "result", "result_type": "success"}
copilot_tool = CopilotTool(
name="direct_tool",
@@ -813,7 +865,7 @@ class TestGitHubCopilotAgentToolConversion:
return arg
async def tool_handler(invocation: Any) -> Any:
return {"textResultForLlm": "result", "resultType": "success"}
return {"text_result_for_llm": "result", "result_type": "success"}
copilot_tool = CopilotTool(
name="direct_tool",
@@ -1,6 +1,6 @@
# Azure AI Agent Examples
This folder contains examples demonstrating different ways to create and use agents with the Azure AI client from the `agent_framework.azure` package. These examples use the `AzureAIClient` with the `azure-ai-projects` 2.x (V2) API surface (see [changelog](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/CHANGELOG.md#200b1-2025-11-11)). For V1 (`azure-ai-agents` 1.x) samples using `AzureAIAgentClient`, see the [Azure AI V1 examples folder](../azure_ai_agent/).
This folder contains examples demonstrating different ways to create and use agents with the Azure AI client from the `agent_framework.azure` package. These examples use the `AzureAIClient` with the `azure-ai-projects` 2.x (V2) API surface (see [changelog](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/CHANGELOG.md#200b1-2025-11-11)). For V1 (`azure-ai-agents` 1.x) samples using `AzureAIAgentClient`, see the [Azure AI V1 examples folder](../azure_ai_agent/). When using preview-only agent creation features on GA SDK versions, create `AIProjectClient` with `allow_preview=True`.
## Examples
@@ -15,16 +15,18 @@ SECURITY NOTE: Only enable file permissions when you trust the agent's actions.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.types import PermissionRequest, PermissionRequestResult
from copilot.generated.session_events import PermissionRequest
from copilot.types import PermissionRequestResult
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
def prompt_permission(
request: PermissionRequest, context: dict[str, str]
) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
kind = request.get("kind", "unknown")
print(f"\n[Permission Request: {kind}]")
print(f"\n[Permission Request: {request.kind}]")
if "path" in request:
print(f" Path: {request.get('path')}")
if request.path is not None:
print(f" Path: {request.path}")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
@@ -15,7 +15,8 @@ of MCP-related actions.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.types import MCPServerConfig, PermissionRequest, PermissionRequestResult
from copilot.generated.session_events import PermissionRequest
from copilot.types import MCPServerConfig, PermissionRequestResult
from dotenv import load_dotenv
# Load environment variables from .env file
@@ -24,8 +25,7 @@ load_dotenv()
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
kind = request.get("kind", "unknown")
print(f"\n[Permission Request: {kind}]")
print(f"\n[Permission Request: {request.kind}]")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
@@ -21,18 +21,18 @@ More permissions mean more potential for unintended actions.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.types import PermissionRequest, PermissionRequestResult
from copilot.generated.session_events import PermissionRequest
from copilot.types import PermissionRequestResult
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
kind = request.get("kind", "unknown")
print(f"\n[Permission Request: {kind}]")
print(f"\n[Permission Request: {request.kind}]")
if "command" in request:
print(f" Command: {request.get('command')}")
if "path" in request:
print(f" Path: {request.get('path')}")
if request.full_command_text is not None:
print(f" Command: {request.full_command_text}")
if request.path is not None:
print(f" Path: {request.path}")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
@@ -14,16 +14,16 @@ Shell commands have full access to your system within the permissions of the run
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.types import PermissionRequest, PermissionRequestResult
from copilot.generated.session_events import PermissionRequest
from copilot.types import PermissionRequestResult
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
kind = request.get("kind", "unknown")
print(f"\n[Permission Request: {kind}]")
print(f"\n[Permission Request: {request.kind}]")
if "command" in request:
print(f" Command: {request.get('command')}")
if request.full_command_text is not None:
print(f" Command: {request.full_command_text}")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
@@ -14,16 +14,16 @@ URL fetching allows the agent to access any URL accessible from your network.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.types import PermissionRequest, PermissionRequestResult
from copilot.generated.session_events import PermissionRequest
from copilot.types import PermissionRequestResult
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
kind = request.get("kind", "unknown")
print(f"\n[Permission Request: {kind}]")
print(f"\n[Permission Request: {request.kind}]")
if "url" in request:
print(f" URL: {request.get('url')}")
if request.url is not None:
print(f" URL: {request.url}")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
+291 -1178
View File
File diff suppressed because it is too large Load Diff