Repository: microsoft/autogen
Pull request: Fix: Azure AI Search Tool Client Lifetime Management
## Why are these changes needed?
This PR fixes a bug where the underlying azure `SearchClient` was being closed prematurely due to use of `async with client` : inside the tool's run method. this caused the users to encounter errors "HTTP transport has already been closed"

## Related issue number

Closes #6308 "

## Checks

- [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally.
- [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR.
- [X] I've made sure all auto checks have passed.


Problem statement:
AzureAISearchTool Not working as expected
### What happened?

**Describe the bug**
Getting following error when I am trying to use Hybrid Search tool with Assistant Agent.

{
    "source": "CodeAnalysisAgent",
    "models_usage": null,
    "metadata": {},
    "content": "\nTool Call Execution Results:\n\nError: Error from Azure AI Search: HTTP transport has already been closed. You may check if you're calling a function outside of the `async with` of your client creation, or if you called `await close()` on your client already.",
    "type": "ToolCallExecutionEvent"
}

Code Snippet:

hybrid_search = AzureAISearchTool.create_hybrid_search(
        name="hybrid_search",
        endpoint=AZURE_AI_SEARCH_ENDPOINT,
        index_name=VLI_DB_SEARCH_INDEX,
        credential=AzureKeyCredential(AZURE_AI_SEARCH_API_KEY),
        vector_fields=["contentVector"],
        search_fields=["title", "content", "url"],
        select_fields=["title", "content", "url"],
        top=10,
    )

db_analyze_agent = AssistantAgent(
        name="DatabaseAnalysisAgent",
        model_client=az_model_client,
        tools=[hybrid_search],
}

**To Reproduce**
Steps to reproduce the behavior. Please include code and outputs such as stacktrace.

- If your input is just "I tried X, and it didn't work" or
"X is not working", your issue will be ignored.
- If your input is not well formatted, it will hurt readability and
may be ignored as well.

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Additional context**
Add any other context about the problem here.


### Which packages was the bug in?

Python AgentChat (autogen-agentchat>=0.4.0)

### AutoGen library version.

Python 0.5.2

### Other library version.

_No response_

### Model used

_No response_

### Model provider

None

### Other model provider

_No response_

### Python version

3.11

### .NET version

None

### Operating system

Windows

Hints from issue discussion:
@jay-thakur can you look into this? 
ack
looked into the issue.

potential root cause is - when we use `async with client` : the client is automatically closed (its HTTP transport is shut down) at the end of the block and if the agent tries to use the client after this block, it will encounter the "HTTP transport has already been closed" error. [here ](https://github.com/microsoft/autogen/blob/main/python/packages/autogen-ext/src/autogen_ext/tools/azure/_ai_search.py#L365)
Thank you so much for raising this issue. will raise the fix soon.

Commit message: fix tests

Patch to review:
diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/azure/_ai_search.py b/python/packages/autogen-ext/src/autogen_ext/tools/azure/_ai_search.py
index ec12a16ca494..355ef364015e 100644
--- a/python/packages/autogen-ext/src/autogen_ext/tools/azure/_ai_search.py
+++ b/python/packages/autogen-ext/src/autogen_ext/tools/azure/_ai_search.py
@@ -241,6 +241,12 @@ def __init__(
         self._client: Optional[SearchClient] = None
         self._cache: Dict[str, Dict[str, Any]] = {}
 
+    async def aclose(self) -> None:
+        """Explicitly close the Azure SearchClient if needed (for cleanup in long-running apps/tests)."""
+        if self._client is not None:
+            await self._client.close()
+            self._client = None
+
     def _process_credential(
         self, credential: Union[AzureKeyCredential, TokenCredential, Dict[str, str]]
     ) -> Union[AzureKeyCredential, TokenCredential]:
@@ -362,61 +368,62 @@ async def run(
             client = await self._get_client()
             results: List[SearchResult] = []
 
-            async with client:
-                search_future = client.search(text_query, **search_options)  # type: ignore
-
-                if cancellation_token is not None:
-                    import asyncio
-
-                    # Using explicit type ignores to handle Azure SDK type complexity
-                    async def awaitable_wrapper():  # type: ignore # pyright: ignore[reportUnknownVariableType,reportUnknownLambdaType,reportUnknownMemberType]
-                        return await search_future  # pyright: ignore[reportUnknownVariableType]
-
-                    task = asyncio.create_task(awaitable_wrapper())  # type: ignore # pyright: ignore[reportUnknownVariableType]
-                    cancellation_token.link_future(task)  # pyright: ignore[reportUnknownArgumentType]
-                    search_results = await task  # pyright: ignore[reportUnknownVariableType]
-                else:
-                    search_results = await search_future  # pyright: ignore[reportUnknownVariableType]
-
-                async for doc in search_results:  # type: ignore
-                    search_doc: Any = doc
-                    doc_dict: Dict[str, Any] = {}
-
-                    try:
-                        if hasattr(search_doc, "items") and callable(search_doc.items):
-                            dict_like_doc = cast(Dict[str, Any], search_doc)
-                            for key, value in dict_like_doc.items():
-                                doc_dict[str(key)] = value
-                        else:
-                            for key in [
-                                k
-                                for k in dir(search_doc)
-                                if not k.startswith("_") and not callable(getattr(search_doc, k, None))
-                            ]:
-                                doc_dict[key] = getattr(search_doc, key)
-                    except Exception as e:
-                        logger.warning(f"Error processing search document: {e}")
-                        continue
-
-                    metadata: Dict[str, Any] = {}
-                    content: Dict[str, Any] = {}
-                    for key, value in doc_dict.items():
-                        key_str: str = str(key)
-                        if key_str.startswith("@") or key_str.startswith("_"):
-                            metadata[key_str] = value
-                        else:
-                            content[key_str] = value
-
-                    score: float = 0.0
-                    if "@search.score" in doc_dict:
-                        score = float(doc_dict["@search.score"])
-
-                    result = SearchResult(
-                        score=score,
-                        content=content,
-                        metadata=metadata,
-                    )
-                    results.append(result)
+            # Use the persistent client directly. Do NOT close after each operation.
+            # WARNING: The SearchClient must live as long as the tool/agent is in use.
+            search_future = client.search(text_query, **search_options)  # type: ignore
+
+            if cancellation_token is not None:
+                import asyncio
+
+                # Using explicit type ignores to handle Azure SDK type complexity
+                async def awaitable_wrapper():  # type: ignore # pyright: ignore[reportUnknownVariableType,reportUnknownLambdaType,reportUnknownMemberType]
+                    return await search_future  # pyright: ignore[reportUnknownVariableType]
+
+                task = asyncio.create_task(awaitable_wrapper())  # type: ignore # pyright: ignore[reportUnknownVariableType]
+                cancellation_token.link_future(task)  # pyright: ignore[reportUnknownArgumentType]
+                search_results = await task  # pyright: ignore[reportUnknownVariableType]
+            else:
+                search_results = await search_future  # pyright: ignore[reportUnknownVariableType]
+
+            async for doc in search_results:  # type: ignore
+                search_doc: Any = doc
+                doc_dict: Dict[str, Any] = {}
+
+                try:
+                    if hasattr(search_doc, "items") and callable(search_doc.items):
+                        dict_like_doc = cast(Dict[str, Any], search_doc)
+                        for key, value in dict_like_doc.items():
+                            doc_dict[str(key)] = value
+                    else:
+                        for key in [
+                            k
+                            for k in dir(search_doc)
+                            if not k.startswith("_") and not callable(getattr(search_doc, k, None))
+                        ]:
+                            doc_dict[key] = getattr(search_doc, key)
+                except Exception as e:
+                    logger.warning(f"Error processing search document: {e}")
+                    continue
+
+                metadata: Dict[str, Any] = {}
+                content: Dict[str, Any] = {}
+                for key, value in doc_dict.items():
+                    key_str: str = str(key)
+                    if key_str.startswith("@") or key_str.startswith("_"):
+                        metadata[key_str] = value
+                    else:
+                        content[key_str] = value
+
+                score: float = 0.0
+                if "@search.score" in doc_dict:
+                    score = float(doc_dict["@search.score"])
+
+                result = SearchResult(
+                    score=score,
+                    content=content,
+                    metadata=metadata,
+                )
+                results.append(result)
 
             if self.search_config.enable_caching:
                 cache_key = f"{text_query}_{self.search_config.top}"


Code review comments:
diff_hunk: @@ -241,6 +241,12 @@ def __init__(
         self._client: Optional[SearchClient] = None
         self._cache: Dict[str, Dict[str, Any]] = {}
 
+    async def aclose(self) -> None:
path: python/packages/autogen-ext/src/autogen_ext/tools/azure/_ai_search.py
text: @user1:
```suggestion
    async def close(self) -> None:
```