Skip to content

Commit 63ce5ee

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

File tree

12 files changed

+695
-120
lines changed

12 files changed

+695
-120
lines changed

agents/chat/src/chat/agent.py

Lines changed: 8 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,10 +160,13 @@ 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)
168+
_e.context["test"] = {"test": "test"}
169+
raise ValueError("Test error")
165170

166171
# Send initial trajectory
167172
yield trajectory.trajectory_metadata(title="Starting", content="Received your request")

apps/agentstack-cli/src/agentstack_cli/commands/agent.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,8 @@ async def _run_agent(
535535
error = ""
536536
if message and message.parts and isinstance(message.parts[0].root, TextPart):
537537
error = message.parts[0].root.text
538-
console.print(f"[red]Task {status}[/red]: {error}")
538+
console.print(f"\n:boom: [red][bold]Task {status.value}[/bold][/red]")
539+
console.print(Markdown(error))
539540
return
540541
case Task(id=task_id), TaskStatusUpdateEvent(
541542
status=TaskStatus(state=TaskState.auth_required, message=message)
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: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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 contextvars
7+
import json
8+
import traceback
9+
from collections.abc import AsyncIterator
10+
from contextlib import asynccontextmanager
11+
from types import NoneType
12+
from typing import Any
13+
14+
import pydantic
15+
16+
from agentstack_sdk.a2a.extensions.base import (
17+
BaseExtensionClient,
18+
BaseExtensionServer,
19+
BaseExtensionSpec,
20+
)
21+
from agentstack_sdk.a2a.types import AgentMessage, JsonDict, Metadata
22+
23+
24+
class Error(pydantic.BaseModel):
25+
"""
26+
Represents error information for displaying exceptions in the UI.
27+
28+
This extension helps display errors in a user-friendly way with:
29+
- A clear error title (exception type)
30+
- A descriptive error message
31+
32+
Visually, this may appear as an error card in the UI.
33+
34+
Properties:
35+
- title: Title of the error (typically the exception class name).
36+
- message: The error message describing what went wrong.
37+
"""
38+
39+
title: str
40+
message: str
41+
42+
43+
class ErrorGroup(pydantic.BaseModel):
44+
"""
45+
Represents a group of errors.
46+
47+
Properties:
48+
- message: A message describing the group of errors.
49+
- errors: A list of error objects.
50+
"""
51+
52+
message: str
53+
errors: list[Error]
54+
55+
56+
class ErrorMetadata(pydantic.BaseModel):
57+
"""
58+
Metadata containing an error (or group of errors) and an optional stack trace.
59+
60+
Properties:
61+
- error: The error object or group of errors.
62+
- stack_trace: Optional formatted stack trace for debugging.
63+
- context: Optional context dictionary.
64+
"""
65+
66+
error: Error | ErrorGroup
67+
stack_trace: str | None = None
68+
context: JsonDict | None = None
69+
70+
71+
class ErrorExtensionParams(pydantic.BaseModel):
72+
"""
73+
Configuration parameters for the error extension.
74+
75+
Properties:
76+
- include_stacktrace: Whether to include stack traces in error messages (default: False).
77+
"""
78+
79+
include_stacktrace: bool = False
80+
81+
82+
class ErrorExtensionSpec(BaseExtensionSpec[ErrorExtensionParams]):
83+
URI: str = "https://a2a-extensions.agentstack.beeai.dev/ui/error/v1"
84+
85+
86+
def _format_stacktrace(exc: BaseException, include_cause: bool = True) -> str:
87+
"""Format exception with full traceback including nested causes."""
88+
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__, chain=include_cause))
89+
90+
91+
def _extract_error(exc: BaseException) -> Error | ErrorGroup:
92+
"""
93+
Extract error information from an exception, handling:
94+
- BaseExceptionGroup (returns ErrorGroup)
95+
- FrameworkError from beeai_framework (uses .explain() method)
96+
"""
97+
# Handle BaseExceptionGroup by recursively extracting errors from each exception
98+
if isinstance(exc, BaseExceptionGroup):
99+
errors: list[Error] = []
100+
for sub_exc in exc.exceptions:
101+
extracted = _extract_error(sub_exc)
102+
if isinstance(extracted, ErrorGroup):
103+
errors.extend(extracted.errors)
104+
else:
105+
errors.append(extracted)
106+
return ErrorGroup(message=str(exc), errors=errors)
107+
108+
# Try to handle FrameworkError if beeai_framework is available
109+
try:
110+
from beeai_framework.errors import FrameworkError
111+
112+
if isinstance(exc, FrameworkError):
113+
# FrameworkError has special .explain() method
114+
return Error(title=exc.name(), message=exc.explain())
115+
except ImportError:
116+
# beeai_framework not installed, continue with standard handling
117+
pass
118+
119+
return Error(title=type(exc).__name__, message=str(exc))
120+
121+
122+
class ErrorExtensionServer(BaseExtensionServer[ErrorExtensionSpec, NoneType]):
123+
def __init__(self, *args: Any, **kwargs: Any) -> None:
124+
super().__init__(*args, **kwargs)
125+
# Server-scoped ContextVar for request-scoped error context
126+
self._error_context_var: contextvars.ContextVar[JsonDict] = contextvars.ContextVar("error_context")
127+
128+
@asynccontextmanager
129+
async def lifespan(self) -> AsyncIterator[None]:
130+
"""Set up request-scoped error context using ContextVar."""
131+
# Set an empty dict for this request's context
132+
token = self._error_context_var.set({})
133+
134+
try:
135+
yield
136+
finally:
137+
self._error_context_var.reset(token)
138+
139+
@property
140+
def context(self) -> JsonDict:
141+
"""Get the current request's error context."""
142+
try:
143+
return self._error_context_var.get()
144+
except LookupError:
145+
# Fallback for when lifespan hasn't been entered yet
146+
return {}
147+
148+
def error_metadata(self, error: BaseException) -> Metadata[str, Any]:
149+
"""
150+
Create metadata for an error.
151+
152+
Args:
153+
error: The exception to convert to metadata
154+
155+
Returns:
156+
Metadata dictionary with error information
157+
"""
158+
error_data = _extract_error(error)
159+
stack_trace = _format_stacktrace(error) if self.spec.params.include_stacktrace else None
160+
return Metadata(
161+
{
162+
self.spec.URI: ErrorMetadata(
163+
error=error_data,
164+
stack_trace=stack_trace,
165+
context=self.context,
166+
).model_dump(mode="json")
167+
}
168+
)
169+
170+
def message(
171+
self,
172+
error: BaseException,
173+
) -> AgentMessage:
174+
"""
175+
Create an AgentMessage with error metadata and serialized text representation.
176+
177+
Args:
178+
error: The exception to include in the message
179+
180+
Returns:
181+
AgentMessage with error metadata and markdown-formatted text
182+
"""
183+
metadata = self.error_metadata(error)
184+
error_metadata = ErrorMetadata.model_validate(metadata[self.spec.URI])
185+
186+
# Serialize to markdown for display
187+
text_lines: list[str] = []
188+
if isinstance(error_metadata.error, ErrorGroup):
189+
text_lines.append(f"## {error_metadata.error.message}\n")
190+
for err in error_metadata.error.errors:
191+
text_lines.append(f"### {err.title}\n{err.message}")
192+
else:
193+
text_lines.append(f"## {error_metadata.error.title}\n{error_metadata.error.message}")
194+
195+
# Add context if present
196+
if error_metadata.context:
197+
text_lines.append(f"## Context\n```json\n{json.dumps(error_metadata.context, indent=2)}\n```")
198+
199+
if error_metadata.stack_trace:
200+
text_lines.append(f"## Stack Trace\n```\n{error_metadata.stack_trace}\n```")
201+
202+
text = "\n\n".join(text_lines)
203+
204+
return AgentMessage(text=text, metadata=metadata)
205+
206+
207+
class ErrorExtensionClient(BaseExtensionClient[ErrorExtensionSpec, ErrorMetadata]): ...

apps/agentstack-sdk-py/src/agentstack_sdk/a2a/types.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# SPDX-License-Identifier: Apache-2.0
33
import typing
44
import uuid
5-
from typing import Generic, Literal, TypeAlias
5+
from typing import Generic, Literal, TypeAlias, TypeAliasType, Union
66

77
from a2a.types import (
88
Artifact,
@@ -104,3 +104,9 @@ def text_message_validate(self):
104104

105105
class AuthRequired(InputRequired):
106106
state: Literal[TaskState.auth_required] = TaskState.auth_required # pyright: ignore [reportIncompatibleVariableOverride]
107+
108+
109+
JsonDict = TypeAliasType(
110+
"JsonDict",
111+
"Union[dict[str, JsonDict], list[JsonDict], str, int, float, bool, None]", # pyright: ignore[reportDeprecated] # noqa: UP007
112+
)

0 commit comments

Comments
 (0)