Python: [BREAKING] Redesign Python exception hierarchy (#4082)

* [BREAKING] Redesign Python exception hierarchy

Replace the flat ServiceException family with domain-scoped branches:
- AgentException (with InvalidAuth, InvalidRequest, InvalidResponse, ContentFilter)
- ChatClientException (same consistent suberrors)
- IntegrationException (same + InitializationError)
- WorkflowException (Runner, Convergence, Checkpoint, Validation, Action, Declarative)
- ContentError (AdditionItemMismatch)
- ToolException / ToolExecutionException (unchanged)
- MiddlewareException / MiddlewareTermination (unchanged)

Key changes:
- All Service* exceptions removed (ServiceException, ServiceInitializationError, etc.)
- AgentExecutionException split into AgentInvalidRequest/ResponseException
- AgentInvocationError removed, split into AgentInvalidRequest/ResponseException
- Workflow exceptions moved from _workflows/_exceptions.py into main exceptions.py
- _workflows/__init__.py emptied; main __init__.py imports directly from submodules
- Purview exceptions re-parented under IntegrationException hierarchy
- Init validation errors use built-in ValueError/TypeError instead of custom exceptions
- CODING_STANDARD.md updated with hierarchy design and rationale

Fixes microsoft/agent-framework#3410

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Clarify ToolException vs ToolExecutionException docstrings

ToolException: base class for all tool-related exceptions (preconditions,
connection/init failures).
ToolExecutionException: runtime call failures (tool call failed, reconnect
failed, MCP errors).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix remaining stale imports from agent_framework._workflows

- azurefunctions: _context.py, _app.py, _serialization.py, test_func_utils.py
  used 'from agent_framework._workflows import X' which broke after
  emptying _workflows/__init__.py; changed to direct submodule imports
- azure-ai-search: test still referenced ServiceInitializationError;
  updated to ValueError to match production code

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-02-19 18:58:14 +01:00
committed by GitHub
Unverified
parent 7f606a2e3a
commit 5ee06853a1
90 changed files with 642 additions and 718 deletions
@@ -19,8 +19,7 @@ from agent_framework import Message
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
from agent_framework.exceptions import (
AgentException,
ServiceInitializationError,
ServiceInvalidRequestError,
IntegrationInvalidRequestException,
)
from redisvl.index import AsyncSearchIndex
from redisvl.query import HybridQuery, TextQuery
@@ -285,7 +284,7 @@ class RedisContextProvider(BaseContextProvider):
existing_sig = _schema_signature(existing_schema)
current_sig = _schema_signature(current_schema)
if existing_sig != current_sig:
raise ServiceInitializationError(
raise ValueError(
"Existing Redis index schema is incompatible with the current configuration.\n"
f"Existing (significant): {json.dumps(existing_sig, indent=2, sort_keys=True)}\n"
f"Current (significant): {json.dumps(current_sig, indent=2, sort_keys=True)}\n"
@@ -313,7 +312,7 @@ class RedisContextProvider(BaseContextProvider):
d.setdefault("thread_id", session_id)
d.setdefault("conversation_id", session_id)
if "content" not in d:
raise ServiceInvalidRequestError("add() requires a 'content' field in data")
raise IntegrationInvalidRequestException("add() requires a 'content' field in data")
if self.vector_field_name:
d.setdefault(self.vector_field_name, None)
prepared.append(d)
@@ -345,7 +344,7 @@ class RedisContextProvider(BaseContextProvider):
q = (text or "").strip()
if not q:
raise ServiceInvalidRequestError("text_search() requires non-empty text")
raise IntegrationInvalidRequestException("text_search() requires non-empty text")
num_results = max(int(num_results or 10), 1)
combined_filter = self._build_filter_from_dict({
@@ -394,14 +393,12 @@ class RedisContextProvider(BaseContextProvider):
text_results = await self.redis_index.query(query)
return cast(list[dict[str, Any]], text_results)
except Exception as exc: # pragma: no cover
raise ServiceInvalidRequestError(f"Redis text search failed: {exc}") from exc
raise IntegrationInvalidRequestException(f"Redis text search failed: {exc}") from exc
def _validate_filters(self) -> None:
"""Validates that at least one filter is provided."""
if not self.agent_id and not self.user_id and not self.application_id:
raise ServiceInitializationError(
"At least one of the filters: agent_id, user_id, or application_id is required."
)
raise ValueError("At least one of the filters: agent_id, user_id, or application_id is required.")
async def search_all(self, page_size: int = 200) -> list[dict[str, Any]]:
"""Returns all documents in the index."""
@@ -10,7 +10,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import AgentResponse, Message
from agent_framework._sessions import AgentSession, SessionContext
from agent_framework.exceptions import ServiceInitializationError
from agent_framework_redis._context_provider import RedisContextProvider
from agent_framework_redis._history_provider import RedisHistoryProvider
@@ -108,7 +107,7 @@ class TestRedisContextProviderInit:
class TestRedisContextProviderValidateFilters:
def test_no_filters_raises(self, patch_index_from_dict: MagicMock): # noqa: ARG002
provider = RedisContextProvider(source_id="ctx")
with pytest.raises(ServiceInitializationError, match="(?i)at least one"):
with pytest.raises(ValueError, match="(?i)at least one"):
provider._validate_filters()
def test_any_single_filter_ok(self, patch_index_from_dict: MagicMock): # noqa: ARG002