Skip to content

Commit 689b80a

Browse files
committed
Improve agent integration tests
1 parent 6f35b62 commit 689b80a

File tree

8 files changed

+494
-202
lines changed

8 files changed

+494
-202
lines changed

python/semantic_kernel/agents/open_ai/responses_agent_thread_actions.py

+28-9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from functools import reduce
77
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast
88

9+
from openai import BadRequestError
910
from openai._streaming import AsyncStream
1011
from openai.types.responses import ResponseFunctionToolCall
1112
from openai.types.responses.response import Response
@@ -28,6 +29,7 @@
2829
merge_function_results,
2930
)
3031
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
32+
from semantic_kernel.connectors.ai.open_ai.exceptions.content_filter_ai_exception import ContentFilterAIException
3133
from semantic_kernel.contents.annotation_content import AnnotationContent
3234
from semantic_kernel.contents.chat_history import ChatHistory
3335
from semantic_kernel.contents.chat_message_content import CMC_ITEM_TYPES, ChatMessageContent
@@ -41,6 +43,7 @@
4143
from semantic_kernel.contents.utils.author_role import AuthorRole
4244
from semantic_kernel.contents.utils.status import Status
4345
from semantic_kernel.exceptions.agent_exceptions import (
46+
AgentExecutionException,
4447
AgentInvokeException,
4548
)
4649
from semantic_kernel.functions.kernel_arguments import KernelArguments
@@ -481,15 +484,31 @@ async def _get_response(
481484
response_options: dict | None = None,
482485
stream: bool = False,
483486
) -> Response | AsyncStream[ResponseStreamEvent]:
484-
response: Response = await agent.client.responses.create(
485-
input=cls._prepare_chat_history_for_request(chat_history),
486-
instructions=merged_instructions or agent.instructions,
487-
previous_response_id=previous_response_id,
488-
store=store_output_enabled,
489-
tools=tools, # type: ignore
490-
stream=stream,
491-
**response_options,
492-
)
487+
try:
488+
response: Response = await agent.client.responses.create(
489+
input=cls._prepare_chat_history_for_request(chat_history),
490+
instructions=merged_instructions or agent.instructions,
491+
previous_response_id=previous_response_id,
492+
store=store_output_enabled,
493+
tools=tools, # type: ignore
494+
stream=stream,
495+
**response_options,
496+
)
497+
except BadRequestError as ex:
498+
if ex.code == "content_filter":
499+
raise ContentFilterAIException(
500+
f"{type(agent)} encountered a content error",
501+
ex,
502+
) from ex
503+
raise AgentExecutionException(
504+
f"{type(agent)} failed to complete the request",
505+
ex,
506+
) from ex
507+
except Exception as ex:
508+
raise AgentExecutionException(
509+
f"{type(agent)} service failed to complete the request",
510+
ex,
511+
) from ex
493512
if response is None:
494513
raise AgentInvokeException("Response is None")
495514
return response

python/tests/integration/agents/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
import asyncio
4+
from collections.abc import AsyncIterator, Awaitable, Callable
5+
from typing import Any, Generic, Protocol, TypeVar
6+
7+
from semantic_kernel.agents.agent import Agent, AgentResponseItem, AgentThread
8+
from semantic_kernel.contents import ChatMessageContent
9+
10+
DEFAULT_MAX_ATTEMPTS = 3
11+
DEFAULT_BACKOFF_SECONDS = 1
12+
13+
14+
class ChatResponseProtocol(Protocol):
15+
"""Represents a single response item returned by the agent."""
16+
17+
@property
18+
def message(self) -> ChatMessageContent: ...
19+
20+
@property
21+
def thread(self) -> AgentThread | None: ...
22+
23+
24+
class ChatAgentProtocol(Protocol):
25+
"""Protocol describing the common agent interface used by the tests."""
26+
27+
async def get_response(
28+
self, messages: str | list[str] | None, thread: object | None = None
29+
) -> ChatResponseProtocol: ...
30+
31+
def invoke(
32+
self, messages: str | list[str] | None, thread: object | None = None
33+
) -> AsyncIterator[ChatResponseProtocol]: ...
34+
35+
def invoke_stream(
36+
self, messages: str | list[str] | None, thread: object | None = None
37+
) -> AsyncIterator[ChatResponseProtocol]: ...
38+
39+
40+
TAgent = TypeVar("TAgent", bound=ChatAgentProtocol)
41+
42+
43+
async def run_with_retry(
44+
coro: Callable[..., Awaitable[Any]],
45+
*args,
46+
attempts: int = DEFAULT_MAX_ATTEMPTS,
47+
backoff_seconds: float = DEFAULT_BACKOFF_SECONDS,
48+
**kwargs,
49+
) -> AgentResponseItem[ChatMessageContent]:
50+
"""
51+
Execute an async callable with retry/backoff logic.
52+
53+
Args:
54+
coro: The async function to call
55+
args: Positional args to pass to the function
56+
attempts: How many times to attempt before giving up
57+
backoff_seconds: The initial backoff in seconds, doubled after each failure
58+
kwargs: Keyword args to pass to the function
59+
60+
Returns:
61+
Whatever the async function returns
62+
63+
Raises:
64+
Exception: If the function fails after the specified number of attempts
65+
"""
66+
delay = backoff_seconds
67+
for attempt in range(1, attempts + 1):
68+
try:
69+
return await coro(*args, **kwargs)
70+
except Exception:
71+
if attempt == attempts:
72+
raise
73+
await asyncio.sleep(delay)
74+
delay *= 2
75+
raise RuntimeError("Unexpected error: run_with_retry exit.")
76+
77+
78+
class AgentTestBase(Generic[TAgent]):
79+
"""Common test base that wraps all agent invocation patterns with retry logic.
80+
81+
Each integration test can inherit from this or use its methods directly.
82+
"""
83+
84+
async def get_response_with_retry(
85+
self,
86+
agent: Agent,
87+
messages: str | list[str] | None,
88+
thread: Any | None = None,
89+
attempts: int = DEFAULT_MAX_ATTEMPTS,
90+
backoff_seconds: float = DEFAULT_BACKOFF_SECONDS,
91+
) -> AgentResponseItem[ChatMessageContent]:
92+
"""Wraps agent.get_response(...) in run_with_retry."""
93+
return await run_with_retry(
94+
agent.get_response, messages=messages, thread=thread, attempts=attempts, backoff_seconds=backoff_seconds
95+
)
96+
97+
async def get_invoke_with_retry(
98+
self,
99+
agent: Any,
100+
messages: str | list[str] | None,
101+
thread: Any | None = None,
102+
attempts: int = DEFAULT_MAX_ATTEMPTS,
103+
backoff_seconds: float = DEFAULT_BACKOFF_SECONDS,
104+
) -> list[AgentResponseItem[ChatMessageContent]]:
105+
"""Wraps agent.invoke(...) in run_with_retry.
106+
107+
Collects generator results in a list before returning them.
108+
"""
109+
return await run_with_retry(
110+
self._collect_from_invoke,
111+
agent,
112+
messages,
113+
thread=thread,
114+
attempts=attempts,
115+
backoff_seconds=backoff_seconds,
116+
)
117+
118+
async def get_invoke_stream_with_retry(
119+
self,
120+
agent: Any,
121+
messages: str | list[str] | None,
122+
thread: Any | None = None,
123+
attempts: int = DEFAULT_MAX_ATTEMPTS,
124+
backoff_seconds: float = DEFAULT_BACKOFF_SECONDS,
125+
) -> list[AgentResponseItem[ChatMessageContent]]:
126+
"""Wraps agent.invoke_stream(...) in run_with_retry.
127+
128+
Collects streaming results in a list before returning them."""
129+
return await run_with_retry(
130+
self._collect_from_invoke_stream,
131+
agent,
132+
messages,
133+
thread=thread,
134+
attempts=attempts,
135+
backoff_seconds=backoff_seconds,
136+
)
137+
138+
async def _collect_from_invoke(
139+
self, agent: Agent, messages: str | list[str] | None, thread: Any | None = None
140+
) -> list[AgentResponseItem[ChatMessageContent]]:
141+
results: list[AgentResponseItem[ChatMessageContent]] = []
142+
async for response in agent.invoke(messages=messages, thread=thread):
143+
results.append(response)
144+
return results
145+
146+
async def _collect_from_invoke_stream(
147+
self, agent: Agent, messages: str | list[str] | None, thread: Any | None = None
148+
) -> list[AgentResponseItem[ChatMessageContent]]:
149+
results: list[AgentResponseItem[ChatMessageContent]] = []
150+
async for response in agent.invoke_stream(messages=messages, thread=thread):
151+
results.append(response)
152+
return results

0 commit comments

Comments
 (0)