Skip to content

Commit b7409d2

Browse files
committed
feat(sdk): add error extension
Signed-off-by: Radek Ježek <radek.jezek@ibm.com>
1 parent 2191fd7 commit b7409d2

File tree

10 files changed

+412
-11
lines changed

10 files changed

+412
-11
lines changed

agents/chat/src/chat/agent.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
BaseExtensionSpec,
2121
CitationExtensionServer,
2222
CitationExtensionSpec,
23+
ErrorExtensionParams,
24+
ErrorExtensionServer,
25+
ErrorExtensionSpec,
2326
TrajectoryExtensionServer,
2427
TrajectoryExtensionSpec,
2528
LLMServiceExtensionServer,
@@ -89,8 +92,7 @@
8992
description="Fetches summaries and information from Wikipedia articles.",
9093
),
9194
AgentDetailTool(
92-
name="Weather Information (OpenMeteo)",
93-
description="Provides real-time weather updates and forecasts.",
95+
name="Weather Information (OpenMeteo)", description="Provides real-time weather updates and forecasts."
9496
),
9597
AgentDetailTool(
9698
name="Web Search (DuckDuckGo)",
@@ -158,7 +160,8 @@ async def chat(
158160
LLMServiceExtensionServer,
159161
LLMServiceExtensionSpec.single_demand(suggested=("openai:gpt-4o", "ollama:granite3.3:8b")),
160162
],
161-
_: Annotated[PlatformApiExtensionServer, PlatformApiExtensionSpec()],
163+
_e: Annotated[ErrorExtensionServer, ErrorExtensionSpec(ErrorExtensionParams(include_stacktrace=True))],
164+
_p: Annotated[PlatformApiExtensionServer, PlatformApiExtensionSpec()],
162165
):
163166
"""Agent with memory and access to web search, Wikipedia, and weather."""
164167
await context.store(input)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import os
5+
from typing import Annotated
6+
7+
from a2a.types import Message
8+
9+
from agentstack_sdk.a2a.extensions.ui.error import (
10+
ErrorExtensionParams,
11+
ErrorExtensionServer,
12+
ErrorExtensionSpec,
13+
)
14+
from agentstack_sdk.server import Server
15+
16+
server = Server()
17+
18+
19+
@server.agent()
20+
async def error_agent(
21+
input: Message,
22+
error_ext: Annotated[
23+
ErrorExtensionServer,
24+
ErrorExtensionSpec(params=ErrorExtensionParams(include_stacktrace=True)),
25+
],
26+
):
27+
"""Agent that demonstrates error handling using the ErrorExtension."""
28+
yield "I am about to fail..."
29+
30+
# Intentionally raise an error to demonstrate the extension
31+
# The server wrapper will catch this and use the injected error_ext to format the message
32+
raise ValueError("This is a simulated error to demonstrate the ErrorExtension.")
33+
34+
35+
def run():
36+
server.run(host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", 10000)))
37+
38+
39+
if __name__ == "__main__":
40+
run()

apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/ui/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from .agent_detail import *
55
from .citation import *
6+
from .error import *
67
from .form_request import *
78
from .settings import *
89
from .trajectory import *
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
import traceback
7+
from types import NoneType
8+
from typing import Any
9+
10+
import pydantic
11+
12+
from agentstack_sdk.a2a.extensions.base import (
13+
BaseExtensionClient,
14+
BaseExtensionServer,
15+
BaseExtensionSpec,
16+
)
17+
from agentstack_sdk.a2a.types import AgentMessage, Metadata
18+
19+
20+
class Error(pydantic.BaseModel):
21+
"""
22+
Represents error information for displaying exceptions in the UI.
23+
24+
This extension helps display errors in a user-friendly way with:
25+
- A clear error title (exception type)
26+
- A descriptive error message
27+
- Optional context dictionary for additional error details
28+
29+
Visually, this may appear as an error card in the UI.
30+
31+
Properties:
32+
- title: Title of the error (typically the exception class name).
33+
- message: The error message describing what went wrong.
34+
- context: Optional dictionary with additional context about the error.
35+
"""
36+
37+
title: str
38+
message: str
39+
context: dict[str, Any] | None = None
40+
41+
42+
class ErrorMetadata(pydantic.BaseModel):
43+
"""
44+
Metadata containing a list of errors and an optional stack trace.
45+
46+
Properties:
47+
- errors: List of error objects describing what went wrong.
48+
- stack_trace: Optional formatted stack trace for debugging (single trace for all errors).
49+
"""
50+
51+
errors: list[Error] = pydantic.Field(default_factory=list)
52+
stack_trace: str | None = None
53+
54+
55+
class ErrorExtensionParams(pydantic.BaseModel):
56+
"""
57+
Configuration parameters for the error extension.
58+
59+
Properties:
60+
- include_stacktrace: Whether to include stack traces in error messages (default: False).
61+
"""
62+
63+
include_stacktrace: bool = False
64+
65+
66+
class ErrorExtensionSpec(BaseExtensionSpec[ErrorExtensionParams]):
67+
URI: str = "https://a2a-extensions.agentstack.beeai.dev/ui/error/v1"
68+
69+
70+
def _format_stacktrace(exc: BaseException, include_cause: bool = True) -> str:
71+
"""Format exception with full traceback including nested causes."""
72+
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__, chain=include_cause))
73+
74+
75+
def _extract_errors(exc: BaseException) -> list[Error]:
76+
"""
77+
Extract error information from an exception, handling:
78+
- BaseExceptionGroup (returns multiple errors)
79+
- FrameworkError from beeai_framework (uses .explain() method)
80+
- Nested exceptions via __cause__ chain
81+
"""
82+
# Handle BaseExceptionGroup by recursively extracting errors from each exception
83+
if isinstance(exc, BaseExceptionGroup):
84+
return [error for sub_exc in exc.exceptions for error in _extract_errors(sub_exc)]
85+
86+
# Try to handle FrameworkError if beeai_framework is available
87+
try:
88+
from beeai_framework.errors import FrameworkError
89+
90+
if isinstance(exc, FrameworkError):
91+
# FrameworkError has special .explain() method and context
92+
return [Error(title=exc.name(), message=exc.explain(), context=exc.context or None)]
93+
except ImportError:
94+
# beeai_framework not installed, continue with standard handling
95+
pass
96+
97+
return [
98+
Error(
99+
title=type(exc).__name__,
100+
message=str(exc),
101+
context=None,
102+
)
103+
]
104+
105+
106+
class ErrorExtensionServer(BaseExtensionServer[ErrorExtensionSpec, NoneType]):
107+
def error_metadata(self, error: BaseException) -> Metadata[str, Any]:
108+
"""
109+
Create metadata for an error.
110+
111+
Args:
112+
error: The exception to convert to metadata
113+
114+
Returns:
115+
Metadata dictionary with error information
116+
"""
117+
errors = _extract_errors(error)
118+
stack_trace = _format_stacktrace(error) if self.spec.params.include_stacktrace else None
119+
return Metadata({self.spec.URI: ErrorMetadata(errors=errors, stack_trace=stack_trace).model_dump(mode="json")})
120+
121+
def message(
122+
self,
123+
error: BaseException,
124+
) -> AgentMessage:
125+
"""
126+
Create an AgentMessage with error metadata and serialized text representation.
127+
128+
Args:
129+
error: The exception to include in the message
130+
131+
Returns:
132+
AgentMessage with error metadata and markdown-formatted text
133+
"""
134+
metadata = self.error_metadata(error)
135+
error_metadata = ErrorMetadata.model_validate(metadata[self.spec.URI])
136+
137+
# Serialize to markdown for display
138+
text_lines = []
139+
for err in error_metadata.errors:
140+
text_lines.append(f"**{err.title}**: {err.message}")
141+
if err.context:
142+
text_lines.append(f"\n*Context*: `{err.context}`")
143+
144+
text = "\n\n".join(text_lines)
145+
146+
# Add stack trace at the end if present
147+
if error_metadata.stack_trace:
148+
text += f"\n\n```\n{error_metadata.stack_trace}\n```"
149+
150+
return AgentMessage(text=text, metadata=metadata)
151+
152+
153+
class ErrorExtensionClient(BaseExtensionClient[ErrorExtensionSpec, ErrorMetadata]): ...

apps/agentstack-sdk-py/src/agentstack_sdk/server/agent.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
import asyncio
55
import inspect
6-
import json
76
from asyncio import CancelledError
87
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator
98
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
@@ -37,14 +36,14 @@
3736
)
3837

3938
from agentstack_sdk.a2a.extensions.ui.agent_detail import AgentDetail, AgentDetailExtensionSpec
39+
from agentstack_sdk.a2a.extensions.ui.error import ErrorExtensionParams, ErrorExtensionServer, ErrorExtensionSpec
4040
from agentstack_sdk.a2a.types import ArtifactChunk, Metadata, RunYield, RunYieldResume
41-
from agentstack_sdk.server.constants import _IMPLICIT_DEPENDENCY_PREFIX
41+
from agentstack_sdk.server.constants import _IMPLICIT_DEPENDENCY_PREFIX, DEFAULT_ERROR_EXTENSION
4242
from agentstack_sdk.server.context import RunContext
4343
from agentstack_sdk.server.dependencies import extract_dependencies
4444
from agentstack_sdk.server.store.context_store import ContextStore
4545
from agentstack_sdk.server.utils import cancel_task, close_queue
4646
from agentstack_sdk.util.logging import logger
47-
from agentstack_sdk.util.utils import extract_messages
4847

4948
AgentFunction: TypeAlias = Callable[[], AsyncGenerator[RunYield, RunYieldResume]]
5049
AgentFunctionFactory: TypeAlias = Callable[
@@ -124,9 +123,14 @@ def agent_factory(context_store: ContextStore):
124123
resolved_name = name or fn.__name__
125124
resolved_description = description or fn.__doc__ or ""
126125

126+
# Check if user has provided an ErrorExtensionServer, if not add default
127+
has_error_extension = any(isinstance(ext, ErrorExtensionServer) for ext in sdk_extensions)
128+
error_extension_spec = ErrorExtensionSpec(ErrorExtensionParams()) if not has_error_extension else None
129+
127130
capabilities.extensions = [
128131
*(capabilities.extensions or []),
129132
*(AgentDetailExtensionSpec(detail).to_agent_card_extensions()),
133+
*(error_extension_spec.to_agent_card_extensions() if error_extension_spec else []),
130134
*(e_card for ext in sdk_extensions for e_card in ext.spec.to_agent_card_extensions()),
131135
]
132136

@@ -232,6 +236,11 @@ async def agent_executor_lifespan(
232236
depends(message, context, dependency_args)
233237
)
234238

239+
context._error_extension = next(
240+
(ext for ext in sdk_extensions if isinstance(ext, ErrorExtensionServer)),
241+
DEFAULT_ERROR_EXTENSION,
242+
)
243+
235244
context._store = await context_store.create(
236245
context_id=request_context.context_id,
237246
initialized_dependencies=list(dependency_args.values()),
@@ -339,8 +348,9 @@ def with_context(message: Message | None = None) -> Message | None:
339348
deep=True, update={"context_id": task_updater.context_id, "task_id": task_updater.task_id}
340349
)
341350

351+
run_context: RunContext | None = None
342352
try:
343-
async with self._agent_executor_span(task_updater, context, context_store) as (execute_fn, _run_context):
353+
async with self._agent_executor_span(task_updater, context, context_store) as (execute_fn, run_context):
344354
agent_generator_fn = execute_fn()
345355

346356
await task_updater.start_work()
@@ -455,8 +465,11 @@ def with_context(message: Message | None = None) -> Message | None:
455465
await task_updater.cancel()
456466
except Exception as ex:
457467
logger.error("Error when executing agent", exc_info=ex)
458-
msg = json.dumps(extract_messages(ex), indent=2)
459-
await task_updater.failed(task_updater.new_agent_message(parts=[Part(root=TextPart(text=msg))]))
468+
# Use the configured error extension from context, or create default with stacktrace enabled
469+
error_extension = run_context._error_extension if run_context else None
470+
error_extension = error_extension if error_extension is not None else DEFAULT_ERROR_EXTENSION
471+
error_msg = error_extension.message(ex)
472+
await task_updater.failed(error_msg)
460473
finally: # cleanup
461474
await cancel_task(cancellation_task)
462475
is_cancelling = bool(current_task.cancelling())

apps/agentstack-sdk-py/src/agentstack_sdk/server/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44
from typing import Final
55

6+
from agentstack_sdk.a2a.extensions.ui.error import ErrorExtensionParams, ErrorExtensionServer, ErrorExtensionSpec
7+
68
_IMPLICIT_DEPENDENCY_PREFIX: Final = "___server_dep"
79

10+
DEFAULT_ERROR_EXTENSION: Final = ErrorExtensionServer(ErrorExtensionSpec(ErrorExtensionParams()))
11+
812
__all__ = []

apps/agentstack-sdk-py/src/agentstack_sdk/server/context.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44

55
from collections.abc import AsyncIterator
6+
from typing import TYPE_CHECKING
67

78
import janus
89
from a2a.server.context import ServerCallContext
@@ -13,6 +14,9 @@
1314
from agentstack_sdk.a2a.types import RunYield, RunYieldResume
1415
from agentstack_sdk.server.store.context_store import ContextStoreInstance
1516

17+
if TYPE_CHECKING:
18+
from agentstack_sdk.a2a.extensions.ui.error import ErrorExtensionServer
19+
1620

1721
class RunContext(BaseModel, arbitrary_types_allowed=True):
1822
configuration: MessageSendConfiguration | None = None
@@ -26,6 +30,7 @@ class RunContext(BaseModel, arbitrary_types_allowed=True):
2630
_store: ContextStoreInstance | None = PrivateAttr(None)
2731
_yield_queue: janus.Queue[RunYield] = PrivateAttr(default_factory=janus.Queue)
2832
_yield_resume_queue: janus.Queue[RunYieldResume] = PrivateAttr(default_factory=janus.Queue)
33+
_error_extension: "ErrorExtensionServer | None" = PrivateAttr(None)
2934

3035
async def store(self, data: Message | Artifact):
3136
if not self._store:

0 commit comments

Comments
 (0)