From 3449902b03178e1c04e58bdfc70bc5521f75a564 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 10 Jul 2025 11:18:15 +0200 Subject: [PATCH] Python: added ChatClientBase with function calling (#147) * added ChatClientBase with function calling * streaming update * fixed typing * test setup * small update * src setup * removed src, updated test naming * fixed test command * alolow args * updated test run * added unit test folder to azure * added init and unit test to azure * added other cross tests * restructured * reset test run * fix name * removed always * updated test * extend pytest.xml locations * run surface always * added decorators for FC and marked tests * fixed mypy settings and added tests * fix override import * removed import --- .github/workflows/python-unit-tests.yml | 9 +- python/.pre-commit-config.yaml | 2 +- python/.vscode/settings.json | 5 - python/agent_framework/_clients.py | 82 --- .../agent_framework/azure/__init__.py | 1 - .../agent_framework/openai/__init__.py | 1 - .../azure}/README.md | 0 .../azure/agent_framework/azure/__init__.py | 10 + .../azure/agent_framework/azure}/py.typed | 0 .../azure}/pyproject.toml | 50 +- .../azure}/tests/__init__.py | 0 .../azure/tests/unit}/__init__.py | 0 .../azure/tests/unit/test_cross_package.py | 32 ++ python/packages/main/.vscode/launch.json | 15 + .../main}/agent_framework/__init__.py | 2 + .../main}/agent_framework/__init__.pyi | 4 +- .../main}/agent_framework/_agents.py | 0 .../agent_framework/_cancellation_token.py | 0 .../packages/main/agent_framework/_clients.py | 530 ++++++++++++++++++ .../main}/agent_framework/_logging.py | 0 .../main}/agent_framework/_pydantic.py | 0 .../main}/agent_framework/_tools.py | 15 +- .../main}/agent_framework/_types.py | 127 +++-- .../main}/agent_framework/exceptions.py | 0 .../main}/agent_framework/guard_rails.py | 0 .../main/agent_framework}/py.typed | 0 .../main}/agent_framework/telemetry.py | 0 python/packages/main/pyproject.toml | 122 ++++ .../main/tests/__init__.py} | 0 .../main/tests/unit/__init__.py} | 0 .../main}/tests/unit/test_agents.py | 0 .../packages/main/tests/unit/test_clients.py | 307 ++++++++++ .../main/tests/unit/test_cross_package.py | 21 + .../main}/tests/unit/test_logging.py | 0 .../main}/tests/unit/test_tool.py | 4 +- .../main}/tests/unit/test_types.py | 0 .../main}/tests/unit/test_version.py | 0 .../py.typed => packages/openai/README.md} | 0 .../agent_framework/openai/__init__.py} | 0 .../openai/agent_framework/openai/py.typed | 0 .../openai/agent_framework/openai/version.py | 10 + .../openai}/pyproject.toml | 45 +- python/packages/openai/tests/__init__.py | 0 python/packages/openai/tests/unit/__init__.py | 0 .../openai/tests/unit/test_cross_package.py | 24 + python/pyproject.toml | 111 +--- ....py => run_tasks_in_packages_if_exists.py} | 0 python/shared_tasks.toml | 5 +- python/tests/unit/test_clients.py | 93 --- python/uv.lock | 107 +++- 50 files changed, 1377 insertions(+), 357 deletions(-) delete mode 100644 python/agent_framework/_clients.py delete mode 100644 python/extensions/agent-framework-azure/agent_framework/azure/__init__.py delete mode 100644 python/extensions/agent-framework-openai/agent_framework/openai/__init__.py rename python/{extensions/agent-framework-azure => packages/azure}/README.md (100%) create mode 100644 python/packages/azure/agent_framework/azure/__init__.py rename python/{agent_framework => packages/azure/agent_framework/azure}/py.typed (100%) rename python/{extensions/agent-framework-azure => packages/azure}/pyproject.toml (60%) rename python/{extensions/agent-framework-azure => packages/azure}/tests/__init__.py (100%) rename python/{extensions/agent-framework-openai/tests => packages/azure/tests/unit}/__init__.py (100%) create mode 100644 python/packages/azure/tests/unit/test_cross_package.py create mode 100644 python/packages/main/.vscode/launch.json rename python/{ => packages/main}/agent_framework/__init__.py (95%) rename python/{ => packages/main}/agent_framework/__init__.pyi (90%) rename python/{ => packages/main}/agent_framework/_agents.py (100%) rename python/{ => packages/main}/agent_framework/_cancellation_token.py (100%) create mode 100644 python/packages/main/agent_framework/_clients.py rename python/{ => packages/main}/agent_framework/_logging.py (100%) rename python/{ => packages/main}/agent_framework/_pydantic.py (100%) rename python/{ => packages/main}/agent_framework/_tools.py (91%) rename python/{ => packages/main}/agent_framework/_types.py (93%) rename python/{ => packages/main}/agent_framework/exceptions.py (100%) rename python/{ => packages/main}/agent_framework/guard_rails.py (100%) rename python/{extensions/agent-framework-azure/agent_framework/azure => packages/main/agent_framework}/py.typed (100%) rename python/{ => packages/main}/agent_framework/telemetry.py (100%) create mode 100644 python/packages/main/pyproject.toml rename python/{extensions/agent-framework-azure/tests/conftest.py => packages/main/tests/__init__.py} (100%) rename python/{extensions/agent-framework-openai/README.md => packages/main/tests/unit/__init__.py} (100%) rename python/{ => packages/main}/tests/unit/test_agents.py (100%) create mode 100644 python/packages/main/tests/unit/test_clients.py create mode 100644 python/packages/main/tests/unit/test_cross_package.py rename python/{ => packages/main}/tests/unit/test_logging.py (100%) rename python/{ => packages/main}/tests/unit/test_tool.py (93%) rename python/{ => packages/main}/tests/unit/test_types.py (100%) rename python/{ => packages/main}/tests/unit/test_version.py (100%) rename python/{extensions/agent-framework-openai/agent_framework/openai/py.typed => packages/openai/README.md} (100%) rename python/{extensions/agent-framework-openai/tests/conftest.py => packages/openai/agent_framework/openai/__init__.py} (100%) create mode 100644 python/packages/openai/agent_framework/openai/py.typed create mode 100644 python/packages/openai/agent_framework/openai/version.py rename python/{extensions/agent-framework-openai => packages/openai}/pyproject.toml (62%) create mode 100644 python/packages/openai/tests/__init__.py create mode 100644 python/packages/openai/tests/unit/__init__.py create mode 100644 python/packages/openai/tests/unit/test_cross_package.py rename python/{run_tasks_in_extensions_if_exists.py => run_tasks_in_packages_if_exists.py} (100%) delete mode 100644 python/tests/unit/test_clients.py diff --git a/.github/workflows/python-unit-tests.yml b/.github/workflows/python-unit-tests.yml index ec9aee0e15..cfdb162fe1 100644 --- a/.github/workflows/python-unit-tests.yml +++ b/.github/workflows/python-unit-tests.yml @@ -35,15 +35,14 @@ jobs: cache-suffix: ${{ runner.os }}-${{ matrix.python-version }} cache-dependency-glob: "**/uv.lock" - name: Install the project - run: uv sync --all-extras --dev -U --prerelease=if-necessary-or-explicit + run: uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit - name: Test with pytest - env: - PYTHON_GIL: ${{ matrix.gil }} - run: uv run --frozen pytest --junitxml=pytest.xml ./tests/unit + run: uv run --frozen poe test --junitxml=pytest.xml - name: Surface failing tests + if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: python/pytest.xml + path: python/**/pytest.xml summary: true display-options: fEX fail-on-empty: true diff --git a/python/.pre-commit-config.yaml b/python/.pre-commit-config.yaml index 57dbfe95cf..50c73e52e8 100644 --- a/python/.pre-commit-config.yaml +++ b/python/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - id: check-json name: Check JSON files files: \.json$ - exclude: ^python\/\.vscode\/.* + exclude: ^.*\.vscode\/.* - id: end-of-file-fixer name: Fix End of File files: \.py$ diff --git a/python/.vscode/settings.json b/python/.vscode/settings.json index b9e3a7c3f7..e52f2e3825 100644 --- a/python/.vscode/settings.json +++ b/python/.vscode/settings.json @@ -19,11 +19,6 @@ "editor.defaultFormatter": "charliermarsh.ruff" }, "python.analysis.autoFormatStrings": true, - "python.analysis.extraPaths": [ - "${workspaceFolder}/agent_framework", - "${workspaceFolder}/extensions/openai", - "${workspaceFolder}/extensions/azure" - ], "python.analysis.importFormat": "relative", "python.analysis.packageIndexDepths": [ { diff --git a/python/agent_framework/_clients.py b/python/agent_framework/_clients.py deleted file mode 100644 index db30ed1505..0000000000 --- a/python/agent_framework/_clients.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from collections.abc import AsyncIterable, Sequence -from typing import Any, Generic, Protocol, TypeVar, runtime_checkable - -from ._types import ChatMessage, ChatResponse, ChatResponseUpdate, GeneratedEmbeddings - -TInput = TypeVar("TInput", contravariant=True) -TEmbedding = TypeVar("TEmbedding") - -# region: ChatClient Protocol - - -@runtime_checkable -class ChatClient(Protocol): - """A protocol for a chat client that can generate responses.""" - - async def get_response( - self, - messages: ChatMessage | Sequence[ChatMessage], - **kwargs: Any, - ) -> ChatResponse: - """Sends input and returns the response. - - Args: - messages: The sequence of input messages to send. - **kwargs: Additional options for the request, such as ai_model_id, temperature, etc. - See `ChatOptions` for more details. - - Returns: - The response messages generated by the client. - - Raises: - ValueError: If the input message sequence is `None`. - """ - ... - - async def get_streaming_response( - self, - messages: ChatMessage | Sequence[ChatMessage], - **kwargs: Any, - ) -> AsyncIterable[ChatResponseUpdate]: - """Sends input messages and streams the response. - - Args: - messages: The sequence of input messages to send. - **kwargs: Additional options for the request, such as ai_model_id, temperature, etc. - See `ChatOptions` for more details. - - Yields: - An async iterable of chat response updates containing the content of the response messages - generated by the client. - - Raises: - ValueError: If the input message sequence is `None`. - """ - ... - - -# region: Embedding Client - - -@runtime_checkable -class EmbeddingGenerator(Protocol, Generic[TInput, TEmbedding]): - """A protocol for an embedding generator that can create embeddings from input data.""" - - async def generate( - self, - input_data: Sequence[TInput], - **kwargs: Any, - ) -> GeneratedEmbeddings[TEmbedding]: - """Generates an embedding for the given input data. - - Args: - input_data: The input data to generate an embedding for. - **kwargs: Additional options for the request. - - Returns: - The generated embedding, this acts like a list, but has additional metadata and usage details. - - """ - ... diff --git a/python/extensions/agent-framework-azure/agent_framework/azure/__init__.py b/python/extensions/agent-framework-azure/agent_framework/azure/__init__.py deleted file mode 100644 index 2a50eae894..0000000000 --- a/python/extensions/agent-framework-azure/agent_framework/azure/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. diff --git a/python/extensions/agent-framework-openai/agent_framework/openai/__init__.py b/python/extensions/agent-framework-openai/agent_framework/openai/__init__.py deleted file mode 100644 index 2a50eae894..0000000000 --- a/python/extensions/agent-framework-openai/agent_framework/openai/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. diff --git a/python/extensions/agent-framework-azure/README.md b/python/packages/azure/README.md similarity index 100% rename from python/extensions/agent-framework-azure/README.md rename to python/packages/azure/README.md diff --git a/python/packages/azure/agent_framework/azure/__init__.py b/python/packages/azure/agent_framework/azure/__init__.py new file mode 100644 index 0000000000..6cfbfc401a --- /dev/null +++ b/python/packages/azure/agent_framework/azure/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = ["__version__"] diff --git a/python/agent_framework/py.typed b/python/packages/azure/agent_framework/azure/py.typed similarity index 100% rename from python/agent_framework/py.typed rename to python/packages/azure/agent_framework/azure/py.typed diff --git a/python/extensions/agent-framework-azure/pyproject.toml b/python/packages/azure/pyproject.toml similarity index 60% rename from python/extensions/agent-framework-azure/pyproject.toml rename to python/packages/azure/pyproject.toml index 5505ce9e93..d06c525295 100644 --- a/python/extensions/agent-framework-azure/pyproject.toml +++ b/python/packages/azure/pyproject.toml @@ -24,28 +24,52 @@ classifiers = [ ] dependencies = [ "agent-framework", - "agent-framework-openai", + "agent-framework-openai" ] +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 + [tool.ruff] extend = "../../pyproject.toml" -include = ["agent-framework/openai/**", "tests/*.py"] - -[tool.poe] -include = "../../shared_tasks.toml" [tool.pyright] -extends = "../../pyproject.toml" -include = ["agent_framework", "samples"] +extend = "../../pyproject.toml" +exclude = ['tests'] -[tool.uv.sources] -agent-framework = { workspace = true } -agent-framework-openai = { workspace = true } +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true +disallow_any_unimported = true + +[tool.bandit] +targets = ["agent_framework"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" [tool.uv.build-backend] -module-name = "azure" -module-root = "agent_framework" +module-name = "agent_framework.azure" +module-root = "" +namespace = true [build-system] requires = ["uv_build>=0.7.19,<0.8.0"] -build-backend = "uv_build" +build-backend = "uv_build" \ No newline at end of file diff --git a/python/extensions/agent-framework-azure/tests/__init__.py b/python/packages/azure/tests/__init__.py similarity index 100% rename from python/extensions/agent-framework-azure/tests/__init__.py rename to python/packages/azure/tests/__init__.py diff --git a/python/extensions/agent-framework-openai/tests/__init__.py b/python/packages/azure/tests/unit/__init__.py similarity index 100% rename from python/extensions/agent-framework-openai/tests/__init__.py rename to python/packages/azure/tests/unit/__init__.py diff --git a/python/packages/azure/tests/unit/test_cross_package.py b/python/packages/azure/tests/unit/test_cross_package.py new file mode 100644 index 0000000000..1c28fbf134 --- /dev/null +++ b/python/packages/azure/tests/unit/test_cross_package.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft. All rights reserved. + +from pytest import mark + + +@mark.xfail(reason="Not solved") +def test_self(): + try: + from agent_framework.azure import __version__ + except ImportError: + __version__ = None + + assert __version__ is not None + + +@mark.xfail(reason="Not solved") +def test_openai(): + try: + from agent_framework.openai import __version__ + except ImportError: + __version__ = None + assert __version__ is not None + + +def test_agent_framework(): + try: + from agent_framework import TextContent + except ImportError: + TextContent = None + assert TextContent is not None + text = TextContent("Hello, world!") + assert text is not None diff --git a/python/packages/main/.vscode/launch.json b/python/packages/main/.vscode/launch.json new file mode 100644 index 0000000000..6b76b4fabc --- /dev/null +++ b/python/packages/main/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/python/agent_framework/__init__.py b/python/packages/main/agent_framework/__init__.py similarity index 95% rename from python/agent_framework/__init__.py rename to python/packages/main/agent_framework/__init__.py index 269797b340..01c5d81b8b 100644 --- a/python/agent_framework/__init__.py +++ b/python/packages/main/agent_framework/__init__.py @@ -35,6 +35,8 @@ _IMPORTS = { "ChatOptions": "._types", "ChatToolMode": "._types", "ChatClient": "._clients", + "ChatClientBase": "._clients", + "use_tool_calling": "._clients", "EmbeddingGenerator": "._clients", "InputGuardrail": ".guard_rails", "OutputGuardrail": ".guard_rails", diff --git a/python/agent_framework/__init__.pyi b/python/packages/main/agent_framework/__init__.pyi similarity index 90% rename from python/agent_framework/__init__.pyi rename to python/packages/main/agent_framework/__init__.pyi index 89c6301740..5d63fd3478 100644 --- a/python/agent_framework/__init__.pyi +++ b/python/packages/main/agent_framework/__init__.pyi @@ -2,7 +2,7 @@ from . import __version__ # type: ignore[attr-defined] from ._agents import Agent, AgentThread -from ._clients import ChatClient, EmbeddingGenerator +from ._clients import ChatClient, ChatClientBase, EmbeddingGenerator, use_tool_calling from ._logging import get_logger from ._tools import AITool, ai_function from ._types import ( @@ -36,6 +36,7 @@ __all__ = [ "Agent", "AgentThread", "ChatClient", + "ChatClientBase", "ChatFinishReason", "ChatMessage", "ChatOptions", @@ -60,4 +61,5 @@ __all__ = [ "__version__", "ai_function", "get_logger", + "use_tool_calling", ] diff --git a/python/agent_framework/_agents.py b/python/packages/main/agent_framework/_agents.py similarity index 100% rename from python/agent_framework/_agents.py rename to python/packages/main/agent_framework/_agents.py diff --git a/python/agent_framework/_cancellation_token.py b/python/packages/main/agent_framework/_cancellation_token.py similarity index 100% rename from python/agent_framework/_cancellation_token.py rename to python/packages/main/agent_framework/_cancellation_token.py diff --git a/python/packages/main/agent_framework/_clients.py b/python/packages/main/agent_framework/_clients.py new file mode 100644 index 0000000000..4aa233d787 --- /dev/null +++ b/python/packages/main/agent_framework/_clients.py @@ -0,0 +1,530 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from abc import ABC, abstractmethod +from collections.abc import AsyncIterable, Awaitable, Callable, MutableSequence, Sequence +from functools import wraps +from typing import Annotated, Any, Generic, Literal, Protocol, TypeVar, runtime_checkable + +from pydantic import BaseModel, PrivateAttr, StringConstraints + +from ._logging import get_logger +from ._pydantic import AFBaseModel +from ._tools import AIFunction, AITool +from ._types import ( + AIContents, + ChatMessage, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + ChatToolMode, + FunctionCallContent, + FunctionResultContent, + GeneratedEmbeddings, +) + +TInput = TypeVar("TInput", contravariant=True) +TEmbedding = TypeVar("TEmbedding") +TInnerGetResponse = TypeVar("TInnerGetResponse", bound=Callable[..., Awaitable[ChatResponse]]) +TInnerGetStreamingResponse = TypeVar( + "TInnerGetStreamingResponse", bound=Callable[..., AsyncIterable[ChatResponseUpdate]] +) + +TChatClientBase = TypeVar("TChatClientBase", bound="ChatClientBase") + +logger = get_logger() + +# region: Tool Calling Functions and Decorators + + +def _merge_function_results( + messages: list[ChatMessage], +) -> ChatMessage: + """Combine multiple function result content types to one chat message content type. + + This method combines the FunctionResultContent items from separate ChatMessageContent messages, + and is used in the event that the `context.terminate = True` condition is met. + """ + contents: list[Any] = [] + for message in messages: + contents.extend([item for item in message.contents if isinstance(item, FunctionResultContent)]) + + return ChatMessage( + role="tool", + contents=contents, + ) + + +async def _auto_invoke_function( + function_call_content: FunctionCallContent, + custom_args: dict[str, Any] | None = None, + *, + tool_map: dict[str, AIFunction[BaseModel, Any]], + sequence_index: int | None = None, + request_index: int | None = None, +) -> AIContents: + """Invoke a function call requested by the agent, applying filters that are defined in the agent.""" + tool: AIFunction[BaseModel, Any] | None = tool_map.get(function_call_content.name) + if tool is None: + raise KeyError(f"No tool or function named '{function_call_content.name}'") + + parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {}) + + # Merge with user-supplied args; right-hand side dominates, so parsed args win on conflicts. + merged_args: dict[str, Any] = (custom_args or {}) | parsed_args + args = tool.input_model.model_validate(merged_args) + exception = None + try: + function_result = await tool.invoke(arguments=args) + except Exception as ex: + exception = ex + function_result = None + return FunctionResultContent( + call_id=function_call_content.call_id, + exception=exception, + result=function_result, + ) + + +def _tool_to_json_schema_spec(tool: AITool) -> dict[str, Any]: + """Convert a AITool to the JSON Schema function specification format.""" + return { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters(), + }, + } + + +def _prepare_tools_and_tool_choice(chat_options: ChatOptions) -> None: + """Prepare the tools and tool choice for the chat options.""" + chat_tool_mode: ChatToolMode | None = chat_options.tool_choice # type: ignore + if chat_tool_mode is None or chat_tool_mode == ChatToolMode.NONE: + chat_options.tools = None + chat_options.tool_choice = ChatToolMode.NONE.mode + return + chat_options.tools = [ + (_tool_to_json_schema_spec(t) if isinstance(t, AITool) else t) for t in chat_options.tools or [] + ] + chat_options.tool_choice = chat_tool_mode.mode + + +def _tool_call_non_streaming(func: TInnerGetResponse) -> TInnerGetResponse: + """Decorate the internal _inner_get_response method to enable tool calls. + + Remarks: + Relies on a class that has the _tool_map attribute for the executable tools to call. + """ + + @wraps(func) + async def wrapper( + self: "ChatClientBase", + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> ChatResponse: + response: ChatResponse | None = None + fcc_messages: list[ChatMessage] = [] + for attempt_idx in range(self.maximum_iterations_per_request): + response = await func(self, messages=messages, chat_options=chat_options) + # if there are function calls, we will handle them first + function_calls = [it for it in response.messages[0].contents if isinstance(it, FunctionCallContent)] + if function_calls: + # Run all function calls concurrently + results = await asyncio.gather(*[ + _auto_invoke_function( + function_call, + custom_args=kwargs, + tool_map=self._tool_map, + sequence_index=seq_idx, + request_index=attempt_idx, + ) + for seq_idx, function_call in enumerate(function_calls) + ]) + # add a single ChatMessage to the response with the results + response.messages.append(ChatMessage(role="tool", contents=results)) + # response should contain 2 messages after this, + # one with function call contents + # and one with function result contents + # the amount and call_id's should match + # this runs in every but the first run + # we need to keep track of all function call messages + fcc_messages.extend(response.messages) + # and add them as additional context to the messages + messages.extend(response.messages) + continue + # If we reach this point, it means there were no function calls to handle, + # we'll add the previous function call and responses + # to the front of the list, so that the final response is the last one + # TODO (eavanvalkenburg): control this behavior? + if fcc_messages: + for msg in reversed(fcc_messages): + response.messages.insert(0, msg) + return response + + # Failsafe: give up on tools, ask model for plain answer + chat_options.tool_choice = "none" + _prepare_tools_and_tool_choice(chat_options=chat_options) + response = await func(self, messages=messages, chat_options=chat_options) + if fcc_messages: + for msg in reversed(fcc_messages): + response.messages.insert(0, msg) + return response + + return wrapper # type: ignore[reportReturnType, return-value] + + +def _tool_call_streaming(func: TInnerGetStreamingResponse) -> TInnerGetStreamingResponse: + """Decorate the internal _inner_get_response method to enable tool calls. + + Remarks: + Relies on a class that has the _tool_map attribute for the executable tools to call. + """ + + @wraps(func) + async def wrapper( + self: "ChatClientBase", + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + for attempt_idx in range(self.maximum_iterations_per_request): + function_call_returned = False + all_messages: list[ChatResponseUpdate] = [] + async for update in func(self, messages=messages, chat_options=chat_options): + if update.contents and any(isinstance(item, FunctionCallContent) for item in update.contents): + all_messages.append(update) + function_call_returned = True + yield update + + if not function_call_returned: + return + + # There is one FunctionCallContent response stream in the messages, combining now to create + # the full completion depending on the prompt, the message may contain both function call + # content and others + response: ChatResponse = ChatResponse.from_chat_response_updates(all_messages) + function_calls = [item for item in response.messages[0].contents if isinstance(item, FunctionCallContent)] + messages.append(response.messages[0]) + + if function_calls: + # Run all function calls concurrently + results = await asyncio.gather(*[ + _auto_invoke_function( + function_call, + custom_args=kwargs, + tool_map=self._tool_map, + sequence_index=seq_idx, + request_index=attempt_idx, + ) + for seq_idx, function_call in enumerate(function_calls) + ]) + yield ChatResponseUpdate(contents=results, role="tool") + response.messages.append(ChatMessage(role="tool", contents=results)) + messages.extend(response.messages) + continue + + # Failsafe: give up on tools, ask model for plain answer + chat_options.tool_choice = "none" + _prepare_tools_and_tool_choice(chat_options=chat_options) + async for update in func(self, messages=messages, chat_options=chat_options, **kwargs): + yield update + + return wrapper # type: ignore[reportReturnType, return-value] + + +def use_tool_calling(cls: type[TChatClientBase]) -> type[TChatClientBase]: + inner_response = getattr(cls, "_inner_get_response", None) + if inner_response is not None: + cls._inner_get_response = _tool_call_non_streaming(inner_response) # type: ignore + inner_streaming_response = getattr(cls, "_inner_get_streaming_response", None) + if inner_streaming_response is not None: + cls._inner_get_streaming_response = _tool_call_streaming(inner_streaming_response) # type: ignore + return cls + + +# region: ChatClient Protocol + + +@runtime_checkable +class ChatClient(Protocol): + """A protocol for a chat client that can generate responses.""" + + async def get_response( + self, + messages: str | ChatMessage | Sequence[ChatMessage], + **kwargs: Any, + ) -> ChatResponse: + """Sends input and returns the response. + + Args: + messages: The sequence of input messages to send. + **kwargs: Additional options for the request, such as ai_model_id, temperature, etc. + See `ChatOptions` for more details. + + Returns: + The response messages generated by the client. + + Raises: + ValueError: If the input message sequence is `None`. + """ + ... + + async def get_streaming_response( + self, + messages: str | ChatMessage | Sequence[ChatMessage], + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + """Sends input messages and streams the response. + + Args: + messages: The sequence of input messages to send. + **kwargs: Additional options for the request, such as ai_model_id, temperature, etc. + See `ChatOptions` for more details. + + Yields: + An async iterable of chat response updates containing the content of the response messages + generated by the client. + + Raises: + ValueError: If the input message sequence is `None`. + """ + ... + + +class ChatClientBase(AFBaseModel, ABC): + """Base class for chat clients.""" + + ai_model_id: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] + maximum_iterations_per_request: int = 10 + _tool_map: dict[str, AIFunction[BaseModel, Any]] = PrivateAttr(default_factory=dict) # type: ignore + + # region Internal methods to be implemented by the derived classes + + @abstractmethod + async def _inner_get_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> ChatResponse: + """Send a chat request to the AI service. + + Args: + messages: The chat messages to send. + chat_options: The options for the request. + kwargs: Any additional keyword arguments. + + Returns: + The chat response contents representing the response(s). + """ + + @abstractmethod + async def _inner_get_streaming_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + """Send a streaming chat request to the AI service. + + Args: + messages: The chat messages to send. + chat_options: The chat_options for the request. + kwargs: Any additional keyword arguments. + + Yields: + ChatResponseUpdate: The streaming chat message contents. + """ + # Below is needed for mypy: https://mypy.readthedocs.io/en/stable/more_types.html#asynchronous-iterators + if False: + yield + await asyncio.sleep(0) # pragma: no cover + # This is a no-op, but it allows the method to be async and return an AsyncIterable. + # The actual implementation should yield ChatResponseUpdate instances as needed. + + # endregion + + # region Public method + + async def get_response( + self, + messages: str | ChatMessage | list[ChatMessage], + *, + model: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None, + tools: Sequence[AITool] | None = None, + response_format: type[BaseModel] | None = None, + user: str | None = None, + stop: str | Sequence[str] | None = None, + frequency_penalty: float | None = None, + logit_bias: dict[str | int, float] | None = None, + presence_penalty: float | None = None, + seed: int | None = None, + store: bool | None = None, + metadata: dict[str, Any] | None = None, + additional_properties: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ChatResponse: + """Get a response from a chat client. + + Args: + messages: the message or messages to send to the model + model: the model to use for the request + max_tokens: the maximum number of tokens to generate + temperature: the sampling temperature to use + top_p: the nucleus sampling probability to use + tool_choice: the tool choice for the request + tools: the tools to use for the request + response_format: the format of the response + user: the user to associate with the request + stop: the stop sequence(s) for the request + frequency_penalty: the frequency penalty to use + logit_bias: the logit bias to use + presence_penalty: the presence penalty to use + seed: the random seed to use + store: whether to store the response + metadata: additional metadata to include in the request + additional_properties: additional properties to include in the request + kwargs: any additional keyword arguments, + will only be passed to functions that are called. + + Returns: + A chat response from the model. + """ + if tools is not None: + self._tool_map = {tool.name: tool for tool in tools if isinstance(tool, AIFunction)} + chat_options = ChatOptions( + ai_model_id=model, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + tool_choice=tool_choice, + tools=tools, + response_format=response_format, + user=user, + stop=stop, + frequency_penalty=frequency_penalty, + logit_bias=logit_bias, + presence_penalty=presence_penalty, + seed=seed, + store=store, + metadata=metadata, + additional_properties=additional_properties or {}, + ) + if isinstance(messages, str): + messages = [ChatMessage(role="user", text=messages)] + if isinstance(messages, ChatMessage): + messages = [messages] + _prepare_tools_and_tool_choice(chat_options=chat_options) + return await self._inner_get_response(messages=messages, chat_options=chat_options, **kwargs) + + async def get_streaming_response( + self, + messages: str | ChatMessage | list[ChatMessage], + *, + model: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None, + tools: Sequence[AITool] | None = None, + response_format: type[BaseModel] | None = None, + user: str | None = None, + stop: str | Sequence[str] | None = None, + frequency_penalty: float | None = None, + logit_bias: dict[str | int, float] | None = None, + presence_penalty: float | None = None, + seed: int | None = None, + store: bool | None = None, + metadata: dict[str, Any] | None = None, + additional_properties: dict[str, Any] | None = None, + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + """Get a streaming response from a chat client. + + Args: + messages: the message or messages to send to the model + model: the model to use for the request + max_tokens: the maximum number of tokens to generate + temperature: the sampling temperature to use + top_p: the nucleus sampling probability to use + tool_choice: the tool choice for the request + tools: the tools to use for the request + response_format: the format of the response + user: the user to associate with the request + stop: the stop sequence(s) for the request + frequency_penalty: the frequency penalty to use + logit_bias: the logit bias to use + presence_penalty: the presence penalty to use + seed: the random seed to use + store: whether to store the response + metadata: additional metadata to include in the request + additional_properties: additional properties to include in the request + kwargs: any additional keyword arguments + + Yields: + A stream representing the response(s) from the LLM. + """ + if tools is not None: + self._tool_map = {tool.name: tool for tool in tools if isinstance(tool, AIFunction)} + chat_options = ChatOptions( + ai_model_id=model, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + tool_choice=tool_choice, + tools=tools, + response_format=response_format, + user=user, + stop=stop, + frequency_penalty=frequency_penalty, + logit_bias=logit_bias, + presence_penalty=presence_penalty, + seed=seed, + store=store, + metadata=metadata, + additional_properties=additional_properties or {}, + **kwargs, + ) + if isinstance(messages, str): + messages = [ChatMessage(role="user", text=messages)] + if isinstance(messages, ChatMessage): + messages = [messages] + _prepare_tools_and_tool_choice(chat_options=chat_options) + async for update in self._inner_get_streaming_response(messages=messages, chat_options=chat_options, **kwargs): + yield update + + +# region: Embedding Client + + +@runtime_checkable +class EmbeddingGenerator(Protocol, Generic[TInput, TEmbedding]): + """A protocol for an embedding generator that can create embeddings from input data.""" + + async def generate( + self, + input_data: Sequence[TInput], + **kwargs: Any, + ) -> GeneratedEmbeddings[TEmbedding]: + """Generates an embedding for the given input data. + + Args: + input_data: The input data to generate an embedding for. + **kwargs: Additional options for the request. + + Returns: + The generated embedding, this acts like a list, but has additional metadata and usage details. + + """ + ... diff --git a/python/agent_framework/_logging.py b/python/packages/main/agent_framework/_logging.py similarity index 100% rename from python/agent_framework/_logging.py rename to python/packages/main/agent_framework/_logging.py diff --git a/python/agent_framework/_pydantic.py b/python/packages/main/agent_framework/_pydantic.py similarity index 100% rename from python/agent_framework/_pydantic.py rename to python/packages/main/agent_framework/_pydantic.py diff --git a/python/agent_framework/_tools.py b/python/packages/main/agent_framework/_tools.py similarity index 91% rename from python/agent_framework/_tools.py rename to python/packages/main/agent_framework/_tools.py index 466b7cbbc9..53c1338dc1 100644 --- a/python/agent_framework/_tools.py +++ b/python/packages/main/agent_framework/_tools.py @@ -2,7 +2,7 @@ import functools import inspect -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from typing import Any, Generic, Protocol, TypeVar, runtime_checkable from pydantic import BaseModel, create_model @@ -23,12 +23,16 @@ class AITool(Protocol): """Return a string representation of the tool.""" ... + def parameters(self) -> Mapping[str, Any]: + """Return the parameters of the tool as a JSON schema.""" + ... + ArgsT = TypeVar("ArgsT", bound=BaseModel) ReturnT = TypeVar("ReturnT") -class AIFunction(Generic[ArgsT, ReturnT]): +class AIFunction(AITool, Generic[ArgsT, ReturnT]): """A tool that represents a function that can be called by an AI service.""" def __init__( @@ -55,14 +59,17 @@ class AIFunction(Generic[ArgsT, ReturnT]): self.additional_properties: dict[str, Any] | None = kwargs self._func = func - def model_json_schema(self) -> dict[str, Any]: - """Return the JSON schema of the input model.""" + def parameters(self) -> dict[str, Any]: + """Return the parameter json schemas of the input model.""" return self.input_model.model_json_schema() def __call__(self, *args: Any, **kwargs: Any) -> ReturnT | Awaitable[ReturnT]: """Call the wrapped function with the provided arguments.""" return self._func(*args, **kwargs) + def __str__(self) -> str: + return f"AIFunction(name={self.name}, description={self.description})" + async def invoke( self, *, diff --git a/python/agent_framework/_types.py b/python/packages/main/agent_framework/_types.py similarity index 93% rename from python/agent_framework/_types.py rename to python/packages/main/agent_framework/_types.py index df86ade3e1..d50f2b1f0a 100644 --- a/python/agent_framework/_types.py +++ b/python/packages/main/agent_framework/_types.py @@ -1,18 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. import base64 +import json import re import sys -from collections.abc import AsyncIterable, Iterable, Iterator, MutableSequence, Sequence +from collections.abc import AsyncIterable, Iterable, Iterator, Mapping, MutableSequence, Sequence from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, overload -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from agent_framework.exceptions import AgentFrameworkException from ._pydantic import AFBaseModel from ._tools import AITool -if sys.version_info >= (3, 12): - pass # pragma: no cover if sys.version_info >= (3, 11): from typing import Self # pragma: no cover else: @@ -170,7 +171,16 @@ def _process_update(response: "ChatResponse", update: "ChatResponseUpdate") -> N if update.message_id: message.message_id = update.message_id for content in update.contents: - if isinstance(content, UsageContent): + if ( + isinstance(content, FunctionCallContent) + and len(message.contents) > 0 + and isinstance(message.contents[-1], FunctionCallContent) + ): + try: + message.contents[-1] += content + except AgentFrameworkException: + message.contents.append(content) + elif isinstance(content, UsageContent): if response.usage_details is None: response.usage_details = UsageDetails() response.usage_details += content.details @@ -579,7 +589,7 @@ class FunctionCallContent(AIContent): """The function call identifier.""" name: str """The name of the function requested.""" - arguments: dict[str, Any | None] | None = None + arguments: str | dict[str, Any | None] | None = None """The arguments requested to be provided to the function.""" exception: Exception | None = None """Any exception that occurred while mapping the original function call data to this representation.""" @@ -589,7 +599,7 @@ class FunctionCallContent(AIContent): *, call_id: str, name: str, - arguments: dict[str, Any | None] | None = None, + arguments: str | dict[str, Any | None] | None = None, exception: Exception | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, @@ -600,7 +610,8 @@ class FunctionCallContent(AIContent): Args: call_id: The function call identifier. name: The name of the function requested. - arguments: The arguments requested to be provided to the function. + arguments: The arguments requested to be provided to the function, + can be a string to allow gradual completion of the args. exception: Any exception that occurred while mapping the original function call data to this representation. additional_properties: Optional additional properties associated with the content. raw_representation: Optional raw representation of the content. @@ -616,6 +627,42 @@ class FunctionCallContent(AIContent): **kwargs, ) + def parse_arguments(self) -> dict[str, Any | None] | None: + if isinstance(self.arguments, str): + # If arguments are a string, try to parse it as JSON + try: + loaded = json.loads(self.arguments) + if isinstance(loaded, dict): + return loaded # type:ignore + return {"raw": loaded} + except (json.JSONDecodeError, TypeError): + return {"raw": self.arguments} + return self.arguments + + def __add__(self, other: "FunctionCallContent") -> "FunctionCallContent": + if not isinstance(other, FunctionCallContent): + raise TypeError("Incompatible type") + if self.call_id != other.call_id: + raise AgentFrameworkException("Incompatible function call contents") + if not self.arguments: + arguments = other.arguments + elif not other.arguments: + arguments = self.arguments + elif isinstance(self.arguments, str) and isinstance(other.arguments, str): + arguments = self.arguments + other.arguments + elif isinstance(self.arguments, dict) and isinstance(other.arguments, dict): + arguments = {**self.arguments, **other.arguments} + else: + raise TypeError("Incompatible argument types") + return FunctionCallContent( + call_id=self.call_id, + name=self.name, + arguments=arguments, + exception=self.exception or other.exception, + additional_properties={**(self.additional_properties or {}), **(other.additional_properties or {})}, + raw_representation=self.raw_representation or other.raw_representation, + ) + class FunctionResultContent(AIContent): """Represents the result of a function call. @@ -1324,6 +1371,14 @@ class ChatToolMode(AFBaseModel): """Returns a ChatToolMode that requires the specified function to be called.""" return cls(mode="required", required_function_name=function_name) + def __eq__(self, other: object) -> bool: + """Checks equality with another ChatToolMode or string.""" + if isinstance(other, str): + return self.mode == other + if isinstance(other, ChatToolMode): + return self.mode == other.mode and self.required_function_name == other.required_function_name + return False + ChatToolMode.AUTO = ChatToolMode(mode="auto") # type: ignore[assignment] ChatToolMode.REQUIRED_ANY = ChatToolMode(mode="required") # type: ignore[assignment] @@ -1334,45 +1389,47 @@ class ChatOptions(AFBaseModel): """Common request settings for AI services.""" ai_model_id: Annotated[str | None, Field(serialization_alias="model")] = None - frequency_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None - logit_bias: dict[str | int, float] | None = None max_tokens: Annotated[int | None, Field(gt=0)] = None - messages: list[dict[str, Any]] | None = Field(default=None, description="List of messages for the chat completion.") - presence_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None + temperature: Annotated[float | None, Field(ge=0.0, le=2.0)] = None + top_p: Annotated[float | None, Field(ge=0.0, le=1.0)] = None + tool_choice: ChatToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None = None + tools: Sequence[AITool] | Sequence[Mapping[str, Any]] | None = None response_format: type[BaseModel] | None = Field( default=None, description="Structured output response format schema. Must be a valid Pydantic model." ) - seed: int | None = None - stop: str | list[str] | None = None - temperature: Annotated[float | None, Field(ge=0.0, le=2.0)] = None - tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None - tools: list[AITool] | None = None - top_p: Annotated[float | None, Field(ge=0.0, le=1.0)] = None user: str | None = None + stop: str | Sequence[str] | None = None + frequency_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None + logit_bias: Mapping[str | int, float] | None = None + presence_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None + seed: int | None = None store: bool | None = None - metadata: dict[str, str] | None = None - additional_properties: dict[str, Any] = Field( + metadata: Mapping[str, str] | None = None + additional_properties: Mapping[str, Any] = Field( default_factory=dict, description="Provider-specific additional properties." ) @field_validator("tool_choice", mode="before") @classmethod - def _validate_tool_mode(cls, data: dict[str, Any]) -> dict[str, Any]: + def _validate_tool_mode( + cls, tool_choice: ChatToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None + ) -> ChatToolMode: """Validates the tool_choice field to ensure it is a valid ChatToolMode.""" - if isinstance(data, dict): - tool_choice = data.get("tool_choice") - if isinstance(tool_choice, str): - if tool_choice == "auto": - data["tool_choice"] = ChatToolMode.AUTO - elif tool_choice == "required": - data["tool_choice"] = ChatToolMode.REQUIRED_ANY - elif tool_choice == "none": - data["tool_choice"] = ChatToolMode.NONE - else: - raise ValueError(f"Invalid tool choice: {tool_choice}") - elif isinstance(tool_choice, dict): - data["tool_choice"] = ChatToolMode.model_validate(tool_choice) - return data + if not tool_choice: + return ChatToolMode.NONE + if isinstance(tool_choice, str): + match tool_choice: + case "auto": + return ChatToolMode.AUTO + case "required": + return ChatToolMode.REQUIRED_ANY + case "none": + return ChatToolMode.NONE + case _: + raise ValidationError(f"Invalid tool choice: {tool_choice}") + if isinstance(tool_choice, (dict, Mapping)): + return ChatToolMode.model_validate(tool_choice) + return tool_choice def to_provider_settings(self, by_alias: bool = True, exclude: set[str] | None = None) -> dict[str, Any]: """Convert the ChatOptions to a dictionary suitable for provider requests. diff --git a/python/agent_framework/exceptions.py b/python/packages/main/agent_framework/exceptions.py similarity index 100% rename from python/agent_framework/exceptions.py rename to python/packages/main/agent_framework/exceptions.py diff --git a/python/agent_framework/guard_rails.py b/python/packages/main/agent_framework/guard_rails.py similarity index 100% rename from python/agent_framework/guard_rails.py rename to python/packages/main/agent_framework/guard_rails.py diff --git a/python/extensions/agent-framework-azure/agent_framework/azure/py.typed b/python/packages/main/agent_framework/py.typed similarity index 100% rename from python/extensions/agent-framework-azure/agent_framework/azure/py.typed rename to python/packages/main/agent_framework/py.typed diff --git a/python/agent_framework/telemetry.py b/python/packages/main/agent_framework/telemetry.py similarity index 100% rename from python/agent_framework/telemetry.py rename to python/packages/main/agent_framework/telemetry.py diff --git a/python/packages/main/pyproject.toml b/python/packages/main/pyproject.toml new file mode 100644 index 0000000000..d90139e2d0 --- /dev/null +++ b/python/packages/main/pyproject.toml @@ -0,0 +1,122 @@ +[project] +name = "agent-framework" +description = "Microsoft Agent Framework for building AI Agents with Python." +authors = [{ name = "Microsoft", email = "SK-Support@microsoft.com"}] +readme = "../../README.md" +requires-python = ">=3.10" +version = "0.1.0b1" +license = {file = "../../LICENSE"} +urls.homepage = "https://learn.microsoft.com/en-us/semantic-kernel/overview/" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 5 - Production/Stable", + "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", + "Framework :: Pydantic :: 2", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-azure", + "agent-framework-openai", + "pydantic>=2.11.7", + "typing-extensions>=4.14.0", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +dev-dependencies = [ + "pre-commit >= 3.7", + "ruff>=0.11.8", + "pytest>=8.4.1", + "pytest-asyncio>=1.0.0", + "pytest-cov>=6.2.1", + "pytest-xdist[psutil]>=3.8.0", + "pytest-timeout>=2.3.1", + "mypy>=1.16.1", + "pyright>=1.1.402", + + #tasks + "poethepoet>=0.36.0", + "rich", + "tomli", + "tomli-w", + "markdownify", + # Documentation + "myst-nb==1.1.2", + "pydata-sphinx-theme==0.16.0", + "sphinx-copybutton", + "sphinx-design", + "sphinx", + "sphinxcontrib-apidoc", + "autodoc_pydantic~=2.2", + "pygments", + "sphinxext-rediraffe", + "opentelemetry-instrumentation-openai", + "markdown-it-py[linkify]", + # Documentation tooling + "diskcache", + "redis", + "sphinx-autobuild", +] +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.pyright] +extend = "../../pyproject.toml" +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true +disallow_any_unimported = true + +[tool.bandit] +targets = ["agent_framework"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.uv.build-backend] +module-name = "agent_framework" +module-root = "" +namespace = true + +[build-system] +requires = ["uv_build>=0.7.19,<0.8.0"] +build-backend = "uv_build" \ No newline at end of file diff --git a/python/extensions/agent-framework-azure/tests/conftest.py b/python/packages/main/tests/__init__.py similarity index 100% rename from python/extensions/agent-framework-azure/tests/conftest.py rename to python/packages/main/tests/__init__.py diff --git a/python/extensions/agent-framework-openai/README.md b/python/packages/main/tests/unit/__init__.py similarity index 100% rename from python/extensions/agent-framework-openai/README.md rename to python/packages/main/tests/unit/__init__.py diff --git a/python/tests/unit/test_agents.py b/python/packages/main/tests/unit/test_agents.py similarity index 100% rename from python/tests/unit/test_agents.py rename to python/packages/main/tests/unit/test_agents.py diff --git a/python/packages/main/tests/unit/test_clients.py b/python/packages/main/tests/unit/test_clients.py new file mode 100644 index 0000000000..68540d6877 --- /dev/null +++ b/python/packages/main/tests/unit/test_clients.py @@ -0,0 +1,307 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import sys +from collections.abc import AsyncIterable, MutableSequence, Sequence +from typing import Any + +from pydantic import Field +from pytest import fixture + +from agent_framework import ( + ChatClient, + ChatClientBase, + ChatMessage, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + ChatRole, + EmbeddingGenerator, + FunctionCallContent, + FunctionResultContent, + GeneratedEmbeddings, + TextContent, + ai_function, + use_tool_calling, +) + +if sys.version_info >= (3, 12): + from typing import override # type: ignore +else: + from typing_extensions import override # type: ignore[import] + + +class MockChatClient: + """Simple implementation of a chat client.""" + + async def get_response( + self, + messages: ChatMessage | Sequence[ChatMessage], + **kwargs: Any, + ) -> ChatResponse: + # Implement the method + + return ChatResponse(messages=ChatMessage(role="assistant", text="test response")) + + async def get_streaming_response( + self, + messages: ChatMessage | Sequence[ChatMessage], + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + # Implement the method + yield ChatResponseUpdate(text=TextContent(text="test streaming response"), role="assistant") + yield ChatResponseUpdate(contents=[TextContent(text="another update")], role="assistant") + + +@use_tool_calling +class MockChatClientBase(ChatClientBase): + """Mock implementation of the ChatClientBase.""" + + run_responses: list[ChatResponse] = Field(default_factory=list) + streaming_responses: list[list[ChatResponseUpdate]] = Field(default_factory=list) + + @override + async def _inner_get_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> ChatResponse: + """Send a chat request to the AI service. + + Args: + messages: The chat messages to send. + chat_options: The options for the request. + kwargs: Any additional keyword arguments. + + Returns: + The chat response contents representing the response(s). + """ + if not self.run_responses or chat_options.tool_choice == "none": + return ChatResponse(messages=ChatMessage(role="assistant", text=f"test response - {messages[0].text}")) + return self.run_responses.pop(0) + + @override + async def _inner_get_streaming_response( + self, + *, + messages: MutableSequence[ChatMessage], + chat_options: ChatOptions, + **kwargs: Any, + ) -> AsyncIterable[ChatResponseUpdate]: + if not self.streaming_responses or chat_options.tool_choice == "none": + yield ChatResponseUpdate(text=f"update - {messages[0].text}", role="assistant") + return + response = self.streaming_responses.pop(0) + for update in response: + yield update + await asyncio.sleep(0) + + +class MockEmbeddingGenerator: + """Simple implementation of an embedding generator.""" + + async def generate( + self, + input_data: Sequence[str], + **kwargs: Any, + ) -> GeneratedEmbeddings[list[float]]: + # Implement the method + embeddings = GeneratedEmbeddings[list[float]]() + for i, _ in enumerate(input_data): + embeddings.append([0.0 * 1, 0.1 * 1, 0.2 * 1, 0.3 * i, 0.4 * i]) + return embeddings + + +@fixture +def chat_client() -> MockChatClient: + return MockChatClient() + + +@fixture +def chat_client_base() -> MockChatClientBase: + return MockChatClientBase(ai_model_id="test") + + +@fixture +def embedding_generator() -> MockEmbeddingGenerator: + gen: EmbeddingGenerator[str, list[float]] = MockEmbeddingGenerator() + return gen + + +def test_chat_client_type(chat_client: MockChatClient): + assert isinstance(chat_client, ChatClient) + + +async def test_chat_client_get_response(chat_client: MockChatClient): + response = await chat_client.get_response(ChatMessage(role="user", text="Hello")) + assert response.text == "test response" + assert response.messages[0].role == ChatRole.ASSISTANT + + +async def test_chat_client_get_streaming_response(chat_client: MockChatClient): + async for update in chat_client.get_streaming_response(ChatMessage(role="user", text="Hello")): + assert update.text == "test streaming response" or update.text == "another update" + assert update.role == ChatRole.ASSISTANT + + +def test_embedding_generator_type(embedding_generator: MockEmbeddingGenerator): + assert isinstance(embedding_generator, EmbeddingGenerator) + + +async def test_embedding_generator_generate(embedding_generator: MockEmbeddingGenerator): + input_data = ["Hello", "world"] + embeddings = await embedding_generator.generate(input_data) + assert len(embeddings) == len(input_data) + for emb in embeddings: + assert len(emb) == 5 + + +def test_base_client(chat_client_base: MockChatClientBase): + assert isinstance(chat_client_base, ChatClientBase) + assert isinstance(chat_client_base, ChatClient) + + +async def test_base_client_get_response(chat_client_base: MockChatClientBase): + response = await chat_client_base.get_response(ChatMessage(role="user", text="Hello")) + assert response.messages[0].role == ChatRole.ASSISTANT + assert response.messages[0].text == "test response - Hello" + + +async def test_base_client_get_streaming_response(chat_client_base: MockChatClientBase): + async for update in chat_client_base.get_streaming_response(ChatMessage(role="user", text="Hello")): + assert update.text == "update - Hello" or update.text == "another update" + + +async def test_base_client_with_function_calling(chat_client_base: MockChatClientBase): + exec_counter = 0 + + @ai_function(name="test_function") + def ai_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func]) + assert exec_counter == 1 + assert len(response.messages) == 3 + assert response.messages[0].role == ChatRole.ASSISTANT + assert isinstance(response.messages[0].contents[0], FunctionCallContent) + assert response.messages[0].contents[0].name == "test_function" + assert response.messages[0].contents[0].arguments == '{"arg1": "value1"}' + assert response.messages[0].contents[0].call_id == "1" + assert response.messages[1].role == ChatRole.TOOL + assert isinstance(response.messages[1].contents[0], FunctionResultContent) + assert response.messages[1].contents[0].call_id == "1" + assert response.messages[1].contents[0].result == "Processed value1" + assert response.messages[2].role == ChatRole.ASSISTANT + assert response.messages[2].text == "done" + + +async def test_base_client_with_function_calling_disabled(chat_client_base: MockChatClientBase): + chat_client_base.maximum_iterations_per_request = 0 + exec_counter = 0 + + @ai_function(name="test_function") + def ai_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func]) + assert exec_counter == 0 + assert len(response.messages) == 1 + assert response.messages[0].role == ChatRole.ASSISTANT + assert response.messages[0].text == "test response - hello" + + +async def test_base_client_with_streaming_function_calling(chat_client_base: MockChatClientBase): + exec_counter = 0 + + @ai_function(name="test_function") + def ai_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1":')], + role="assistant", + ), + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="test_function", arguments='"value1"}')], + role="assistant", + ), + ], + [ + ChatResponseUpdate( + contents=[TextContent(text="Processed value1")], + role="assistant", + ) + ], + ] + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]): + updates.append(update) + assert len(updates) == 4 # two updates with the function call, the function result and the final text + assert updates[0].contents[0].call_id == "1" + assert updates[1].contents[0].call_id == "1" + assert updates[2].contents[0].call_id == "1" + assert updates[3].text == "Processed value1" + assert exec_counter == 1 + + +async def test_base_client_with_streaming_function_calling_disabled(chat_client_base: MockChatClientBase): + chat_client_base.maximum_iterations_per_request = 0 + exec_counter = 0 + + @ai_function(name="test_function") + def ai_func(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Processed {arg1}" + + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1":')], + role="assistant", + ), + ChatResponseUpdate( + contents=[FunctionCallContent(call_id="1", name="test_function", arguments='"value1"}')], + role="assistant", + ), + ], + [ + ChatResponseUpdate( + contents=[TextContent(text="Processed value1")], + role="assistant", + ) + ], + ] + updates = [] + async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]): + updates.append(update) + assert len(updates) == 1 + assert exec_counter == 0 diff --git a/python/packages/main/tests/unit/test_cross_package.py b/python/packages/main/tests/unit/test_cross_package.py new file mode 100644 index 0000000000..b51d95437c --- /dev/null +++ b/python/packages/main/tests/unit/test_cross_package.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft. All rights reserved. + +from pytest import mark + + +@mark.xfail(reason="Not solved") +def test_openai(): + try: + from agent_framework.openai import __version__ + except ImportError: + __version__ = None + assert __version__ is not None + + +@mark.xfail(reason="Not solved") +def test_azure(): + try: + from agent_framework.azure import __version__ + except ImportError: + __version__ = None + assert __version__ is not None diff --git a/python/tests/unit/test_logging.py b/python/packages/main/tests/unit/test_logging.py similarity index 100% rename from python/tests/unit/test_logging.py rename to python/packages/main/tests/unit/test_logging.py diff --git a/python/tests/unit/test_tool.py b/python/packages/main/tests/unit/test_tool.py similarity index 93% rename from python/tests/unit/test_tool.py rename to python/packages/main/tests/unit/test_tool.py index aa8e69d5dd..2d8c011362 100644 --- a/python/tests/unit/test_tool.py +++ b/python/packages/main/tests/unit/test_tool.py @@ -14,7 +14,7 @@ def test_ai_function_decorator(): assert isinstance(test_tool, AITool) assert test_tool.name == "test_tool" assert test_tool.description == "A test tool" - assert test_tool.model_json_schema() == { + assert test_tool.parameters() == { "properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}}, "required": ["x", "y"], "title": "test_tool_input", @@ -34,7 +34,7 @@ async def test_ai_function_decorator_with_async(): assert isinstance(async_test_tool, AITool) assert async_test_tool.name == "async_test_tool" assert async_test_tool.description == "An async test tool" - assert async_test_tool.model_json_schema() == { + assert async_test_tool.parameters() == { "properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}}, "required": ["x", "y"], "title": "async_test_tool_input", diff --git a/python/tests/unit/test_types.py b/python/packages/main/tests/unit/test_types.py similarity index 100% rename from python/tests/unit/test_types.py rename to python/packages/main/tests/unit/test_types.py diff --git a/python/tests/unit/test_version.py b/python/packages/main/tests/unit/test_version.py similarity index 100% rename from python/tests/unit/test_version.py rename to python/packages/main/tests/unit/test_version.py diff --git a/python/extensions/agent-framework-openai/agent_framework/openai/py.typed b/python/packages/openai/README.md similarity index 100% rename from python/extensions/agent-framework-openai/agent_framework/openai/py.typed rename to python/packages/openai/README.md diff --git a/python/extensions/agent-framework-openai/tests/conftest.py b/python/packages/openai/agent_framework/openai/__init__.py similarity index 100% rename from python/extensions/agent-framework-openai/tests/conftest.py rename to python/packages/openai/agent_framework/openai/__init__.py diff --git a/python/packages/openai/agent_framework/openai/py.typed b/python/packages/openai/agent_framework/openai/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/packages/openai/agent_framework/openai/version.py b/python/packages/openai/agent_framework/openai/version.py new file mode 100644 index 0000000000..6cfbfc401a --- /dev/null +++ b/python/packages/openai/agent_framework/openai/version.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = ["__version__"] diff --git a/python/extensions/agent-framework-openai/pyproject.toml b/python/packages/openai/pyproject.toml similarity index 62% rename from python/extensions/agent-framework-openai/pyproject.toml rename to python/packages/openai/pyproject.toml index c59423a5d2..9e08fa23a6 100644 --- a/python/extensions/agent-framework-openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -27,25 +27,48 @@ dependencies = [ "openai>=1.93.0", ] +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 [tool.ruff] extend = "../../pyproject.toml" -include = ["agent-framework/openai/**", "tests/*.py"] - -[tool.poe] -include = "../../shared_tasks.toml" [tool.pyright] -extends = "../../pyproject.toml" -include = ["agent_framework", "samples"] +extend = "../../pyproject.toml" +exclude = ['tests', ".venv"] -[tool.uv.sources] -agent-framework = { workspace = true } +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true +disallow_any_unimported = true + +[tool.bandit] +targets = ["agent_framework"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" [tool.uv.build-backend] -module-name = "openai" -module-root = "agent_framework" +module-name = "agent_framework.openai" +module-root = "" [build-system] requires = ["uv_build>=0.7.19,<0.8.0"] -build-backend = "uv_build" +build-backend = "uv_build" \ No newline at end of file diff --git a/python/packages/openai/tests/__init__.py b/python/packages/openai/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/packages/openai/tests/unit/__init__.py b/python/packages/openai/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/packages/openai/tests/unit/test_cross_package.py b/python/packages/openai/tests/unit/test_cross_package.py new file mode 100644 index 0000000000..4a14ad6b88 --- /dev/null +++ b/python/packages/openai/tests/unit/test_cross_package.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft. All rights reserved. + + +from pytest import mark + + +@mark.xfail(reason="Not solved") +def test_self(): + try: + from agent_framework.openai import __version__ + except ImportError: + __version__ = None + + assert __version__ is not None + + +def test_agent_framework(): + try: + from agent_framework import TextContent + except ImportError: + TextContent = None + assert TextContent is not None + text = TextContent("Hello, world!") + assert text is not None diff --git a/python/pyproject.toml b/python/pyproject.toml index ab3bd7c78d..95e1498820 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,37 +1,10 @@ [project] -name = "agent-framework" +name = "agent-framework-project" description = "Microsoft Agent Framework for building AI Agents with Python." -authors = [{ name = "Microsoft", email = "SK-Support@microsoft.com"}] -readme = "README.md" -requires-python = ">=3.10" -version = "0.1.0b1" -license = {file = "LICENSE"} -urls.homepage = "https://learn.microsoft.com/en-us/semantic-kernel/overview/" -urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" -urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" -urls.issues = "https://github.com/microsoft/agent-framework/issues" -classifiers = [ - "License :: OSI Approved :: MIT License", - "Development Status :: 5 - Production/Stable", - "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", - "Framework :: Pydantic :: 2", - "Typing :: Typed", -] -dependencies = [ - "agent-framework-azure", - "agent-framework-openai", - "pydantic>=2.11.7", - "typing-extensions>=4.14.0", -] +version = "0.0.0" -[tool.uv] -prerelease = "if-necessary-or-explicit" -dev-dependencies = [ +[dependency-groups] +dev = [ "pre-commit >= 3.7", "ruff>=0.11.8", "pytest>=8.4.1", @@ -65,24 +38,21 @@ dev-dependencies = [ "redis", "sphinx-autobuild", ] + +[tool.uv] +package = false +prerelease = "if-necessary-or-explicit" environments = [ "sys_platform == 'darwin'", "sys_platform == 'linux'", "sys_platform == 'win32'" ] -[tool.pytest.ini_options] -testpaths = 'tests' -addopts = "-ra -q -r fEX" -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" -filterwarnings = [] -timeout = 120 - [tool.uv.workspace] -members = ["extensions/*"] +members = ["packages/*"] [tool.uv.sources] +agent-framework = { workspace = true } agent-framework-openai = { workspace = true } agent-framework-azure = { workspace = true } @@ -94,7 +64,7 @@ line-length = 120 target-version = "py310" fix = true include = ["*.py", "*.pyi", "**/pyproject.toml", "*.ipynb"] -exclude = ["docs/*", "run_tasks_in_extensions_if_exists.py", "check_md_code_blocks.py"] +exclude = ["docs/*", "run_tasks_in_packages_if_exists.py", "check_md_code_blocks.py"] preview = true [tool.ruff.lint] @@ -133,7 +103,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] # Ignore all directories named `tests` and `samples`. -"tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"] +"**/tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"] "samples/**" = ["D", "INP", "ERA001", "RUF", "S"] "*.ipynb" = ["CPY", "E501"] @@ -149,6 +119,7 @@ min-file-size = 1 [tool.pyright] include = ["agent_framework", "samples"] +exclude = ["**/tests/**", "docs", "**/.venv/**"] typeCheckingMode = "strict" reportUnnecessaryIsInstance = false reportMissingTypeStubs = false @@ -170,52 +141,32 @@ disallow_any_unimported = true [tool.bandit] targets = ["agent_framework"] -exclude_dirs = ["tests", "./run_tasks_in_extensions_if_exists.py", "./check_md_code_blocks.py", "docs"] +exclude_dirs = ["tests", "./run_tasks_in_packages_if_exists.py", "./check_md_code_blocks.py", "docs"] + +[tool.poe] +executor.type = "uv" [tool.poe.tasks] markdown-code-lint = """python check_md_code_blocks.py ../README.md ./docs/agent-framework/**/*.md README.md""" samples-code-check = """pyright ./samples""" docs-clean = "rm -rf docs/build" docs-build = "sphinx-build docs/agent-framework docs/build" -docs-serve = "sphinx-autobuild --watch src docs/agent-framework docs/build --port 8000 --jobs auto" +docs-serve = "sphinx-autobuild --watch docs/agent-framework docs/build --port 8000 --jobs auto" docs-check = "sphinx-build --fail-on-warning docs/agent-framework docs/build" docs-check-examples = "sphinx-build -b code_lint docs/agent-framework docs/build" pre-commit-install = "uv run pre-commit install --install-hooks --overwrite" -install = "uv sync --all-extras --dev -U --prerelease=if-necessary-or-explicit" +install = "uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit" +test = "python run_tasks_in_packages_if_exists.py test" +fmt = "python run_tasks_in_packages_if_exists.py fmt" format.ref = "fmt" +lint = "python run_tasks_in_packages_if_exists.py lint" +pyright = "python run_tasks_in_packages_if_exists.py pyright" +mypy = "python run_tasks_in_packages_if_exists.py mypy" +build = "python run_tasks_in_packages_if_exists.py build" +# combined checks check = ["fmt", "lint", "pyright", "mypy", "test", "markdown-code-lint", "samples-code-check"] -pre-commit-check = ["fmt", "lint", "pyright", "test", "markdown-code-lint", "samples-code-check"] +pre-commit-check = ["fmt", "lint", "pyright", "markdown-code-lint", "samples-code-check"] -[tool.poe.tasks.fmt] -sequence = [ - { cmd = "python run_tasks_in_extensions_if_exists.py fmt"}, - { cmd = "ruff format agent_framework tests samples" } -] -[tool.poe.tasks.lint] -sequence = [ - { cmd = "python run_tasks_in_extensions_if_exists.py lint" }, - { cmd = "ruff check --fix --exit-non-zero-on-fix agent_framework tests samples" } -] -[tool.poe.tasks.pyright] -sequence = [ - { cmd = "python run_tasks_in_extensions_if_exists.py pyright" }, - { cmd = "pyright" } -] -[tool.poe.tasks.mypy] -sequence = [ - { cmd = "python run_tasks_in_extensions_if_exists.py mypy" }, - { cmd = "mypy --config-file pyproject.toml agent_framework" } -] -[tool.poe.tasks.test] -sequence = [ - { cmd = "python run_tasks_in_extensions_if_exists.py test" }, - { cmd = "pytest --cov=agent_framework --cov-report=term-missing:skip-covered tests/unit/" } -] -[tool.poe.tasks.build] -sequence = [ - { cmd = "python run_tasks_in_extensions_if_exists.py build" }, - { cmd = "uv build" } -] [tool.poe.tasks.venv] cmd = "uv venv --python $python" args = [{ name = "python", default = "3.13", options = ['-p', '--python'] }] @@ -226,11 +177,3 @@ sequence = [ { ref = "pre-commit-install" } ] args = [{ name = "python", default = "3.13", options = ['-p', '--python'] }] - -[tool.uv.build-backend] -module-name = "agent_framework" -module-root = "" - -[build-system] -requires = ["uv_build>=0.7.19,<0.8.0"] -build-backend = "uv_build" diff --git a/python/run_tasks_in_extensions_if_exists.py b/python/run_tasks_in_packages_if_exists.py similarity index 100% rename from python/run_tasks_in_extensions_if_exists.py rename to python/run_tasks_in_packages_if_exists.py diff --git a/python/shared_tasks.toml b/python/shared_tasks.toml index 7028712c02..ba4c972a25 100644 --- a/python/shared_tasks.toml +++ b/python/shared_tasks.toml @@ -2,6 +2,7 @@ fmt = "ruff format" format.ref = "fmt" lint = "ruff check" -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework tests" +mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework" pyright = "pyright" -build = "uv build" \ No newline at end of file +build = "uv build" +test = "pytest --cov=agent_framework --cov-report=term-missing:skip-covered tests/unit" \ No newline at end of file diff --git a/python/tests/unit/test_clients.py b/python/tests/unit/test_clients.py deleted file mode 100644 index ac15bd8b67..0000000000 --- a/python/tests/unit/test_clients.py +++ /dev/null @@ -1,93 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from collections.abc import AsyncIterable, Sequence -from typing import Any - -from pytest import fixture - -from agent_framework import ( - ChatClient, - ChatMessage, - ChatResponse, - ChatResponseUpdate, - ChatRole, - EmbeddingGenerator, - GeneratedEmbeddings, - TextContent, -) - - -class ImplementedChatClient: - """Simple implementation of a chat client.""" - - async def get_response( - self, - messages: ChatMessage | Sequence[ChatMessage], - **kwargs: Any, - ) -> ChatResponse: - # Implement the method - - return ChatResponse(messages=ChatMessage(role="assistant", text="test response")) - - async def get_streaming_response( - self, - messages: ChatMessage | Sequence[ChatMessage], - **kwargs: Any, - ) -> AsyncIterable[ChatResponseUpdate]: - # Implement the method - yield ChatResponseUpdate(text=TextContent(text="test streaming response"), role="assistant") - yield ChatResponseUpdate(contents=[TextContent(text="another update")], role="assistant") - - -class ImplementedEmbeddingGenerator: - """Simple implementation of an embedding generator.""" - - async def generate( - self, - input_data: Sequence[str], - **kwargs: Any, - ) -> GeneratedEmbeddings[list[float]]: - # Implement the method - embeddings = GeneratedEmbeddings[list[float]]() - for i, _ in enumerate(input_data): - embeddings.append([0.0 * 1, 0.1 * 1, 0.2 * 1, 0.3 * i, 0.4 * i]) - return embeddings - - -@fixture -def chat_client() -> ImplementedChatClient: - return ImplementedChatClient() - - -@fixture -def embedding_generator() -> ImplementedEmbeddingGenerator: - gen: EmbeddingGenerator[str, list[float]] = ImplementedEmbeddingGenerator() - return gen - - -def test_chat_client_type(chat_client: ImplementedChatClient): - assert isinstance(chat_client, ChatClient) - - -async def test_chat_client_get_response(chat_client: ImplementedChatClient): - response = await chat_client.get_response(ChatMessage(role="user", text="Hello")) - assert response.text == "test response" - assert response.messages[0].role == ChatRole.ASSISTANT - - -async def test_chat_client_get_streaming_response(chat_client: ImplementedChatClient): - async for update in chat_client.get_streaming_response(ChatMessage(role="user", text="Hello")): - assert update.text == "test streaming response" or update.text == "another update" - assert update.role == ChatRole.ASSISTANT - - -def test_embedding_generator_type(embedding_generator: ImplementedEmbeddingGenerator): - assert isinstance(embedding_generator, EmbeddingGenerator) - - -async def test_embedding_generator_generate(embedding_generator: ImplementedEmbeddingGenerator): - input_data = ["Hello", "world"] - embeddings = await embedding_generator.generate(input_data) - assert len(embeddings) == len(input_data) - for emb in embeddings: - assert len(emb) == 5 diff --git a/python/uv.lock b/python/uv.lock index 1dfc66f560..ff9204cf1b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -20,6 +20,7 @@ members = [ "agent-framework", "agent-framework-azure", "agent-framework-openai", + "agent-framework-project", ] [[package]] @@ -37,7 +38,7 @@ wheels = [ [[package]] name = "agent-framework" version = "0.1.0b1" -source = { editable = "." } +source = { editable = "packages/main" } dependencies = [ { name = "agent-framework-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -80,8 +81,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agent-framework-azure", editable = "extensions/agent-framework-azure" }, - { name = "agent-framework-openai", editable = "extensions/agent-framework-openai" }, + { name = "agent-framework-azure", editable = "packages/azure" }, + { name = "agent-framework-openai", editable = "packages/openai" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "typing-extensions", specifier = ">=4.14.0" }, ] @@ -121,7 +122,7 @@ dev = [ [[package]] name = "agent-framework-azure" version = "0.1.0b1" -source = { editable = "extensions/agent-framework-azure" } +source = { editable = "packages/azure" } dependencies = [ { name = "agent-framework", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -129,14 +130,14 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "agent-framework", editable = "." }, - { name = "agent-framework-openai", editable = "extensions/agent-framework-openai" }, + { name = "agent-framework", editable = "packages/main" }, + { name = "agent-framework-openai", editable = "packages/openai" }, ] [[package]] name = "agent-framework-openai" version = "0.1.0b1" -source = { editable = "extensions/agent-framework-openai" } +source = { editable = "packages/openai" } dependencies = [ { name = "agent-framework", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -144,10 +145,82 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "agent-framework", editable = "." }, + { name = "agent-framework", editable = "packages/main" }, { name = "openai", specifier = ">=1.93.0" }, ] +[[package]] +name = "agent-framework-project" +version = "0.0.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "autodoc-pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "diskcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "markdown-it-py", extra = ["linkify"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "markdownify", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "myst-nb", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pre-commit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydata-sphinx-theme", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest-timeout", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest-xdist", extra = ["psutil"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "sphinx-autobuild", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sphinx-copybutton", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sphinx-design", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sphinxcontrib-apidoc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sphinxext-rediraffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tomli", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tomli-w", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "autodoc-pydantic", specifier = "~=2.2" }, + { name = "diskcache" }, + { name = "markdown-it-py", extras = ["linkify"] }, + { name = "markdownify" }, + { name = "mypy", specifier = ">=1.16.1" }, + { name = "myst-nb", specifier = "==1.1.2" }, + { name = "opentelemetry-instrumentation-openai" }, + { name = "poethepoet", specifier = ">=0.36.0" }, + { name = "pre-commit", specifier = ">=3.7" }, + { name = "pydata-sphinx-theme", specifier = "==0.16.0" }, + { name = "pygments" }, + { name = "pyright", specifier = ">=1.1.402" }, + { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=6.2.1" }, + { name = "pytest-timeout", specifier = ">=2.3.1" }, + { name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.8.0" }, + { name = "redis" }, + { name = "rich" }, + { name = "ruff", specifier = ">=0.11.8" }, + { name = "sphinx" }, + { name = "sphinx-autobuild" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-design" }, + { name = "sphinxcontrib-apidoc" }, + { name = "sphinxext-rediraffe" }, + { name = "tomli" }, + { name = "tomli-w" }, +] + [[package]] name = "alabaster" version = "1.0.0" @@ -255,11 +328,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.6.15" +version = "2025.7.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload-time = "2025-07-09T02:13:58.874Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, + { url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload-time = "2025-07-09T02:13:57.007Z" }, ] [[package]] @@ -1280,7 +1353,7 @@ wheels = [ [[package]] name = "openai" -version = "1.93.0" +version = "1.93.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1292,9 +1365,9 @@ dependencies = [ { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/d7/e91c6a9cf71726420cddf539852ee4c29176ebb716a702d9118d0409fd8e/openai-1.93.0.tar.gz", hash = "sha256:988f31ade95e1ff0585af11cc5a64510225e4f5cd392698c675d0a9265b8e337", size = 486573, upload-time = "2025-06-27T21:21:39.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/2b/0d93a981afe76b4e89c57b69bf421b5f15878983fccbad321f142ba6e89b/openai-1.93.2.tar.gz", hash = "sha256:4a7312b426b5e4c98b78dfa1148b5683371882de3ad3d5f7c8e0c74f3cc90778", size = 487225, upload-time = "2025-07-08T15:38:00.218Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/46/a10d9df4673df56f71201d129ba1cb19eaff3366d08c8664d61a7df52e65/openai-1.93.0-py3-none-any.whl", hash = "sha256:3d746fe5498f0dd72e0d9ab706f26c91c0f646bf7459e5629af8ba7c9dbdf090", size = 755038, upload-time = "2025-06-27T21:21:37.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/22/f7b90b519e8a5867dc96d411615eb7f987d2d5474c22e7d37c7170a132da/openai-1.93.2-py3-none-any.whl", hash = "sha256:5adbbebd48eae160e6d68efc4c0a4f7cb1318a44c62d9fc626cec229f418eab4", size = 755084, upload-time = "2025-07-08T15:37:58.045Z" }, ] [[package]] @@ -1671,15 +1744,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.402" +version = "1.1.403" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/04/ce0c132d00e20f2d2fb3b3e7c125264ca8b909e693841210534b1ea1752f/pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683", size = 3888207, upload-time = "2025-06-11T08:48:35.759Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526, upload-time = "2025-07-09T07:15:52.882Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/37/1a1c62d955e82adae588be8e374c7f77b165b6cb4203f7d581269959abbc/pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982", size = 5624004, upload-time = "2025-06-11T08:48:33.998Z" }, + { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" }, ] [[package]]