acp-sdk 0.8.4__tar.gz → 0.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/PKG-INFO +2 -2
  2. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/pyproject.toml +2 -2
  3. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/client/client.py +5 -6
  4. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/models/models.py +6 -0
  5. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/app.py +9 -6
  6. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/bundle.py +4 -0
  7. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/server.py +153 -16
  8. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/telemetry.py +6 -2
  9. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/e2e/fixtures/server.py +6 -0
  10. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/e2e/test_suites/test_runs.py +3 -2
  11. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/unit/client/test_client.py +22 -0
  12. acp_sdk-0.9.1/tests/unit/server/__init__.py +0 -0
  13. acp_sdk-0.9.1/tests/unit/server/test_server.py +31 -0
  14. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/.gitignore +0 -0
  15. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/.python-version +0 -0
  16. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/README.md +0 -0
  17. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/docs/.gitignore +0 -0
  18. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/docs/Makefile +0 -0
  19. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/docs/conf.py +0 -0
  20. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/docs/index.rst +0 -0
  21. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/docs/make.bat +0 -0
  22. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/pytest.ini +0 -0
  23. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/__init__.py +0 -0
  24. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/client/__init__.py +0 -0
  25. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/client/types.py +0 -0
  26. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/client/utils.py +0 -0
  27. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/instrumentation.py +0 -0
  28. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/models/__init__.py +0 -0
  29. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/models/errors.py +0 -0
  30. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/models/schemas.py +0 -0
  31. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/py.typed +0 -0
  32. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/__init__.py +0 -0
  33. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/agent.py +0 -0
  34. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/context.py +0 -0
  35. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/errors.py +0 -0
  36. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/logging.py +0 -0
  37. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/session.py +0 -0
  38. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/types.py +0 -0
  39. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/server/utils.py +0 -0
  40. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/src/acp_sdk/version.py +0 -0
  41. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/conftest.py +0 -0
  42. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/e2e/__init__.py +0 -0
  43. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/e2e/config.py +0 -0
  44. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/e2e/fixtures/__init__.py +0 -0
  45. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/e2e/fixtures/client.py +0 -0
  46. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/e2e/test_suites/__init__.py +0 -0
  47. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/e2e/test_suites/test_discovery.py +0 -0
  48. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/unit/client/test_utils.py +0 -0
  49. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/unit/models/__init__.py +0 -0
  50. {acp_sdk-0.8.4 → acp_sdk-0.9.1}/tests/unit/models/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acp-sdk
3
- Version: 0.8.4
3
+ Version: 0.9.1
4
4
  Summary: Agent Communication Protocol SDK
5
5
  Author: IBM Corp.
6
6
  Maintainer-email: Tomas Pilar <thomas7pilar@gmail.com>
@@ -16,7 +16,7 @@ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.31.1
16
16
  Requires-Dist: opentelemetry-instrumentation-fastapi>=0.52b1
17
17
  Requires-Dist: opentelemetry-instrumentation-httpx>=0.52b1
18
18
  Requires-Dist: opentelemetry-sdk>=1.31.1
19
- Requires-Dist: pydantic>=2.11.1
19
+ Requires-Dist: pydantic>=2.0.0
20
20
  Description-Content-Type: text/markdown
21
21
 
22
22
  # Agent Communication Protocol SDK for Python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acp-sdk"
3
- version = "0.8.4"
3
+ version = "0.9.1"
4
4
  description = "Agent Communication Protocol SDK"
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
@@ -9,7 +9,7 @@ maintainers = [{ name = "Tomas Pilar", email = "thomas7pilar@gmail.com" }]
9
9
  requires-python = ">=3.11, <4.0"
10
10
  dependencies = [
11
11
  "opentelemetry-api>=1.31.1",
12
- "pydantic>=2.11.1",
12
+ "pydantic>=2.0.0",
13
13
  "httpx>=0.26.0",
14
14
  "httpx-sse>=0.4.0",
15
15
  "opentelemetry-instrumentation-httpx>=0.52b1",
@@ -8,7 +8,6 @@ from typing import Self
8
8
 
9
9
  import httpx
10
10
  from httpx_sse import EventSource, aconnect_sse
11
- from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
12
11
  from pydantic import TypeAdapter
13
12
 
14
13
  from acp_sdk.client.types import Input
@@ -22,6 +21,7 @@ from acp_sdk.models import (
22
21
  AgentsListResponse,
23
22
  AwaitResume,
24
23
  Error,
24
+ ErrorEvent,
25
25
  Event,
26
26
  PingResponse,
27
27
  Run,
@@ -43,7 +43,6 @@ class Client:
43
43
  *,
44
44
  session_id: SessionId | None = None,
45
45
  client: httpx.AsyncClient | None = None,
46
- instrument: bool = True,
47
46
  auth: httpx._types.AuthTypes | None = None,
48
47
  params: httpx._types.QueryParamTypes | None = None,
49
48
  headers: httpx._types.HeaderTypes | None = None,
@@ -84,8 +83,6 @@ class Client:
84
83
  transport=transport,
85
84
  trust_env=trust_env,
86
85
  )
87
- if instrument:
88
- HTTPXClientInstrumentor.instrument_client(self._client)
89
86
 
90
87
  @property
91
88
  def client(self) -> httpx.AsyncClient:
@@ -107,7 +104,7 @@ class Client:
107
104
  async def session(self, session_id: SessionId | None = None) -> AsyncGenerator[Self]:
108
105
  session_id = session_id or uuid.uuid4()
109
106
  with get_tracer().start_as_current_span("session", attributes={"acp.session": str(session_id)}):
110
- yield Client(client=self._client, session_id=session_id, instrument=False)
107
+ yield Client(client=self._client, session_id=session_id)
111
108
 
112
109
  async def agents(self) -> AsyncIterator[Agent]:
113
110
  response = await self._client.get("/agents")
@@ -224,7 +221,9 @@ class Client:
224
221
  await event_source.response.aread()
225
222
  self._raise_error(event_source.response)
226
223
  async for event in event_source.aiter_sse():
227
- event = TypeAdapter(Event).validate_json(event.data)
224
+ event: Event = TypeAdapter(Event).validate_json(event.data)
225
+ if isinstance(event, ErrorEvent):
226
+ raise ACPError(error=event.error)
228
227
  yield event
229
228
 
230
229
  def _raise_error(self, response: httpx.Response) -> None:
@@ -262,7 +262,13 @@ class RunCompletedEvent(BaseModel):
262
262
  run: Run
263
263
 
264
264
 
265
+ class ErrorEvent(BaseModel):
266
+ type: Literal["error"] = "error"
267
+ error: Error
268
+
269
+
265
270
  Event = Union[
271
+ ErrorEvent,
266
272
  RunCreatedEvent,
267
273
  RunInProgressEvent,
268
274
  MessageCreatedEvent,
@@ -6,9 +6,9 @@ from enum import Enum
6
6
 
7
7
  from cachetools import TTLCache
8
8
  from fastapi import Depends, FastAPI, HTTPException, status
9
+ from fastapi.applications import AppType, Lifespan
9
10
  from fastapi.encoders import jsonable_encoder
10
11
  from fastapi.responses import JSONResponse, StreamingResponse
11
- from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
12
12
 
13
13
  from acp_sdk.models import (
14
14
  Agent as AgentModel,
@@ -53,24 +53,27 @@ def create_app(
53
53
  *agents: Agent,
54
54
  run_limit: int = 1000,
55
55
  run_ttl: timedelta = timedelta(hours=1),
56
+ lifespan: Lifespan[AppType] | None = None,
56
57
  dependencies: list[Depends] | None = None,
57
58
  ) -> FastAPI:
58
59
  executor: ThreadPoolExecutor
59
60
 
60
61
  @asynccontextmanager
61
- async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
62
+ async def internal_lifespan(app: FastAPI) -> AsyncGenerator[None]:
62
63
  nonlocal executor
63
64
  with ThreadPoolExecutor() as exec:
64
65
  executor = exec
65
- yield
66
+ if not lifespan:
67
+ yield None
68
+ else:
69
+ async with lifespan(app) as state:
70
+ yield state
66
71
 
67
72
  app = FastAPI(
68
- lifespan=lifespan,
73
+ lifespan=internal_lifespan,
69
74
  dependencies=dependencies,
70
75
  )
71
76
 
72
- FastAPIInstrumentor.instrument_app(app)
73
-
74
77
  agents: dict[AgentName, Agent] = {agent.name: agent for agent in agents}
75
78
  runs: TTLCache[RunId, RunBundle] = TTLCache(maxsize=run_limit, ttl=run_ttl, timer=datetime.now)
76
79
  sessions: TTLCache[SessionId, Session] = TTLCache(maxsize=run_limit, ttl=run_ttl, timer=datetime.now)
@@ -142,6 +142,8 @@ class RunBundle:
142
142
  run_logger.info("Run resumed")
143
143
  elif isinstance(next, Error):
144
144
  raise ACPError(error=next)
145
+ elif isinstance(next, ACPError):
146
+ raise next
145
147
  elif next is None:
146
148
  await flush_message()
147
149
  elif isinstance(next, BaseModel):
@@ -176,3 +178,5 @@ class RunBundle:
176
178
  finally:
177
179
  self.await_or_terminate_event.set()
178
180
  await self.stream_queue.put(None)
181
+ if not self.task.done():
182
+ self.task.cancel()
@@ -1,12 +1,14 @@
1
1
  import asyncio
2
2
  import os
3
- from collections.abc import Awaitable
3
+ from collections.abc import AsyncGenerator, Awaitable
4
+ from contextlib import asynccontextmanager
4
5
  from datetime import timedelta
5
6
  from typing import Any, Callable
6
7
 
7
8
  import requests
8
9
  import uvicorn
9
10
  import uvicorn.config
11
+ from fastapi import FastAPI
10
12
 
11
13
  from acp_sdk.models import Metadata
12
14
  from acp_sdk.server.agent import Agent
@@ -20,8 +22,8 @@ from acp_sdk.server.utils import async_request_with_retry
20
22
 
21
23
  class Server:
22
24
  def __init__(self) -> None:
23
- self._agents: list[Agent] = []
24
- self._server: uvicorn.Server | None = None
25
+ self.agents: list[Agent] = []
26
+ self.server: uvicorn.Server | None = None
25
27
 
26
28
  def agent(
27
29
  self,
@@ -40,10 +42,15 @@ class Server:
40
42
  return decorator
41
43
 
42
44
  def register(self, *agents: Agent) -> None:
43
- self._agents.extend(agents)
45
+ self.agents.extend(agents)
44
46
 
45
- def run(
47
+ @asynccontextmanager
48
+ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None]:
49
+ yield
50
+
51
+ async def serve(
46
52
  self,
53
+ *,
47
54
  configure_logger: bool = True,
48
55
  configure_telemetry: bool = False,
49
56
  self_registration: bool = True,
@@ -101,18 +108,25 @@ class Server:
101
108
  factory: bool = False,
102
109
  h11_max_incomplete_event_size: int | None = None,
103
110
  ) -> None:
104
- if self._server:
111
+ if self.server:
105
112
  raise RuntimeError("The server is already running")
106
113
 
114
+ if headers is None:
115
+ headers = [("server", "acp")]
116
+ elif not any(k.lower() == "server" for k, _ in headers):
117
+ headers.append(("server", "acp"))
118
+
107
119
  import uvicorn
108
120
 
121
+ app = create_app(*self.agents, lifespan=self.lifespan, run_limit=run_limit, run_ttl=run_ttl)
122
+
109
123
  if configure_logger:
110
124
  configure_logger_func()
111
125
  if configure_telemetry:
112
- configure_telemetry_func()
126
+ configure_telemetry_func(app)
113
127
 
114
128
  config = uvicorn.Config(
115
- create_app(*self._agents, run_limit=run_limit, run_ttl=run_ttl),
129
+ app,
116
130
  host,
117
131
  port,
118
132
  uds,
@@ -161,23 +175,139 @@ class Server:
161
175
  factory,
162
176
  h11_max_incomplete_event_size,
163
177
  )
164
- self._server = uvicorn.Server(config)
178
+ self.server = uvicorn.Server(config)
179
+ await self._serve(self_registration=self_registration)
165
180
 
166
- asyncio.run(self._serve(self_registration=self_registration))
181
+ def run(
182
+ self,
183
+ *,
184
+ configure_logger: bool = True,
185
+ configure_telemetry: bool = False,
186
+ self_registration: bool = True,
187
+ run_limit: int = 1000,
188
+ run_ttl: timedelta = timedelta(hours=1),
189
+ host: str = "127.0.0.1",
190
+ port: int = 8000,
191
+ uds: str | None = None,
192
+ fd: int | None = None,
193
+ loop: uvicorn.config.LoopSetupType = "auto",
194
+ http: type[asyncio.Protocol] | uvicorn.config.HTTPProtocolType = "auto",
195
+ ws: type[asyncio.Protocol] | uvicorn.config.WSProtocolType = "auto",
196
+ ws_max_size: int = 16 * 1024 * 1024,
197
+ ws_max_queue: int = 32,
198
+ ws_ping_interval: float | None = 20.0,
199
+ ws_ping_timeout: float | None = 20.0,
200
+ ws_per_message_deflate: bool = True,
201
+ lifespan: uvicorn.config.LifespanType = "auto",
202
+ env_file: str | os.PathLike[str] | None = None,
203
+ log_config: dict[str, Any]
204
+ | str
205
+ | uvicorn.config.RawConfigParser
206
+ | uvicorn.config.IO[Any]
207
+ | None = uvicorn.config.LOGGING_CONFIG,
208
+ log_level: str | int | None = None,
209
+ access_log: bool = True,
210
+ use_colors: bool | None = None,
211
+ interface: uvicorn.config.InterfaceType = "auto",
212
+ reload: bool = False,
213
+ reload_dirs: list[str] | str | None = None,
214
+ reload_delay: float = 0.25,
215
+ reload_includes: list[str] | str | None = None,
216
+ reload_excludes: list[str] | str | None = None,
217
+ workers: int | None = None,
218
+ proxy_headers: bool = True,
219
+ server_header: bool = True,
220
+ date_header: bool = True,
221
+ forwarded_allow_ips: list[str] | str | None = None,
222
+ root_path: str = "",
223
+ limit_concurrency: int | None = None,
224
+ limit_max_requests: int | None = None,
225
+ backlog: int = 2048,
226
+ timeout_keep_alive: int = 5,
227
+ timeout_notify: int = 30,
228
+ timeout_graceful_shutdown: int | None = None,
229
+ callback_notify: Callable[..., Awaitable[None]] | None = None,
230
+ ssl_keyfile: str | os.PathLike[str] | None = None,
231
+ ssl_certfile: str | os.PathLike[str] | None = None,
232
+ ssl_keyfile_password: str | None = None,
233
+ ssl_version: int = uvicorn.config.SSL_PROTOCOL_VERSION,
234
+ ssl_cert_reqs: int = uvicorn.config.ssl.CERT_NONE,
235
+ ssl_ca_certs: str | None = None,
236
+ ssl_ciphers: str = "TLSv1",
237
+ headers: list[tuple[str, str]] | None = None,
238
+ factory: bool = False,
239
+ h11_max_incomplete_event_size: int | None = None,
240
+ ) -> None:
241
+ asyncio.run(
242
+ self.serve(
243
+ configure_logger=configure_logger,
244
+ configure_telemetry=configure_telemetry,
245
+ self_registration=self_registration,
246
+ run_limit=run_limit,
247
+ run_ttl=run_ttl,
248
+ host=host,
249
+ port=port,
250
+ uds=uds,
251
+ fd=fd,
252
+ loop=loop,
253
+ http=http,
254
+ ws=ws,
255
+ ws_max_size=ws_max_size,
256
+ ws_max_queue=ws_max_queue,
257
+ ws_ping_interval=ws_ping_interval,
258
+ ws_ping_timeout=ws_ping_timeout,
259
+ ws_per_message_deflate=ws_per_message_deflate,
260
+ lifespan=lifespan,
261
+ env_file=env_file,
262
+ log_config=log_config,
263
+ log_level=log_level,
264
+ access_log=access_log,
265
+ use_colors=use_colors,
266
+ interface=interface,
267
+ reload=reload,
268
+ reload_dirs=reload_dirs,
269
+ reload_delay=reload_delay,
270
+ reload_includes=reload_includes,
271
+ reload_excludes=reload_excludes,
272
+ workers=workers,
273
+ proxy_headers=proxy_headers,
274
+ server_header=server_header,
275
+ date_header=date_header,
276
+ forwarded_allow_ips=forwarded_allow_ips,
277
+ root_path=root_path,
278
+ limit_concurrency=limit_concurrency,
279
+ limit_max_requests=limit_max_requests,
280
+ backlog=backlog,
281
+ timeout_keep_alive=timeout_keep_alive,
282
+ timeout_notify=timeout_notify,
283
+ timeout_graceful_shutdown=timeout_graceful_shutdown,
284
+ callback_notify=callback_notify,
285
+ ssl_keyfile=ssl_keyfile,
286
+ ssl_certfile=ssl_certfile,
287
+ ssl_keyfile_password=ssl_keyfile_password,
288
+ ssl_version=ssl_version,
289
+ ssl_cert_reqs=ssl_cert_reqs,
290
+ ssl_ca_certs=ssl_ca_certs,
291
+ ssl_ciphers=ssl_ciphers,
292
+ headers=headers,
293
+ factory=factory,
294
+ h11_max_incomplete_event_size=h11_max_incomplete_event_size,
295
+ )
296
+ )
167
297
 
168
298
  async def _serve(self, self_registration: bool = True) -> None:
169
299
  registration_task = asyncio.create_task(self._register_agent()) if self_registration else None
170
- await self._server.serve()
300
+ await self.server.serve()
171
301
  if registration_task:
172
302
  registration_task.cancel()
173
303
 
174
304
  @property
175
305
  def should_exit(self) -> bool:
176
- return self._server.should_exit if self._server else False
306
+ return self.server.should_exit if self.server else False
177
307
 
178
308
  @should_exit.setter
179
309
  def should_exit(self, value: bool) -> None:
180
- self._server.should_exit = value
310
+ self.server.should_exit = value
181
311
 
182
312
  async def _register_agent(self) -> None:
183
313
  """If not in PRODUCTION mode, register agent to the beeai platform and provide missing env variables"""
@@ -187,7 +317,7 @@ class Server:
187
317
 
188
318
  url = os.getenv("PLATFORM_URL", "http://127.0.0.1:8333")
189
319
  request_data = {
190
- "location": f"http://{self._server.config.host}:{self._server.config.port}",
320
+ "location": f"http://{self.server.config.host}:{self.server.config.port}",
191
321
  }
192
322
  try:
193
323
  await async_request_with_retry(
@@ -198,7 +328,7 @@ class Server:
198
328
  # check missing env keyes
199
329
  envs_request = await async_request_with_retry(lambda client: client.get(f"{url}/api/v1/variables"))
200
330
  envs = envs_request.get("env")
201
- for agent in self._agents:
331
+ for agent in self.agents:
202
332
  # register all available envs
203
333
  missing_keyes = []
204
334
  for env in agent.metadata.model_dump().get("env", []):
@@ -215,4 +345,11 @@ class Server:
215
345
  except requests.exceptions.ConnectionError as e:
216
346
  logger.warning(f"Can not reach server, check if running on {url} : {e}")
217
347
  except (requests.exceptions.HTTPError, Exception) as e:
218
- logger.warning(f"Agent can not be registered to beeai server: {e}")
348
+ try:
349
+ error_message = e.response.json().get("detail")
350
+ if error_message:
351
+ logger.warning(f"Agent can not be registered to beeai server: {error_message}")
352
+ else:
353
+ logger.warning(f"Agent can not be registered to beeai server: {e}")
354
+ except Exception:
355
+ logger.warning(f"Agent can not be registered to beeai server: {e}")
@@ -1,9 +1,11 @@
1
1
  import logging
2
2
 
3
+ from fastapi import FastAPI
3
4
  from opentelemetry import metrics, trace
4
5
  from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
5
6
  from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
6
7
  from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
8
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
7
9
  from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
8
10
  from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
9
11
  from opentelemetry.sdk.metrics import MeterProvider
@@ -22,8 +24,10 @@ from acp_sdk.version import __version__
22
24
  root_logger = logging.getLogger()
23
25
 
24
26
 
25
- def configure_telemetry() -> None:
26
- """Utility that configures opentelemetry with OTLP exporter"""
27
+ def configure_telemetry(app: FastAPI) -> None:
28
+ """Utility that configures opentelemetry with OTLP exporter and FastAPI instrumentation"""
29
+
30
+ FastAPIInstrumentor.instrument_app(app)
27
31
 
28
32
  resource = Resource(
29
33
  attributes={
@@ -7,6 +7,7 @@ from threading import Thread
7
7
 
8
8
  import pytest
9
9
  from acp_sdk.models import Artifact, AwaitResume, Error, ErrorCode, Message, MessageAwaitRequest, MessagePart
10
+ from acp_sdk.models.errors import ACPError
10
11
  from acp_sdk.server import Context, Server
11
12
 
12
13
  from e2e.config import Config
@@ -38,6 +39,11 @@ def server(request: pytest.FixtureRequest) -> Generator[None]:
38
39
  @server.agent()
39
40
  async def failer(input: list[Message], context: Context) -> AsyncIterator[Message]:
40
41
  yield Error(code=ErrorCode.INVALID_INPUT, message="Wrong question buddy!")
42
+ raise RuntimeError("Unreachable code")
43
+
44
+ @server.agent()
45
+ async def raiser(input: list[Message], context: Context) -> AsyncIterator[Message]:
46
+ raise ACPError(Error(code=ErrorCode.INVALID_INPUT, message="Wrong question buddy!"))
41
47
 
42
48
  @server.agent()
43
49
  async def sessioner(input: list[Message], context: Context) -> AsyncIterator[Message]:
@@ -72,8 +72,9 @@ async def test_run_events_are_stream(server: Server, client: Client) -> None:
72
72
 
73
73
 
74
74
  @pytest.mark.asyncio
75
- async def test_failure(server: Server, client: Client) -> None:
76
- run = await client.run_sync(agent="failer", input=input)
75
+ @pytest.mark.parametrize("agent", ["failer", "raiser"])
76
+ async def test_failure(server: Server, client: Client, agent: AgentName) -> None:
77
+ run = await client.run_sync(agent=agent, input=input)
77
78
  assert run.status == RunStatus.FAILED
78
79
  assert run.error is not None
79
80
  assert run.error.code == ErrorCode.INVALID_INPUT
@@ -4,8 +4,12 @@ import uuid
4
4
  import pytest
5
5
  from acp_sdk.client import Client
6
6
  from acp_sdk.models import (
7
+ ACPError,
7
8
  Agent,
8
9
  AgentsListResponse,
10
+ Error,
11
+ ErrorCode,
12
+ ErrorEvent,
9
13
  Message,
10
14
  MessageAwaitResume,
11
15
  MessagePart,
@@ -77,6 +81,24 @@ async def test_run_stream(httpx_mock: HTTPXMock) -> None:
77
81
  assert event == mock_event
78
82
 
79
83
 
84
+ @pytest.mark.asyncio
85
+ async def test_run_stream_error(httpx_mock: HTTPXMock) -> None:
86
+ error = Error(code=ErrorCode.SERVER_ERROR, message="whoops")
87
+ mock_event = ErrorEvent(error=error)
88
+ httpx_mock.add_response(
89
+ url="http://test/runs",
90
+ method="POST",
91
+ headers={"content-type": "text/event-stream"},
92
+ content=f"data: {mock_event.model_dump_json()}\n\n",
93
+ )
94
+
95
+ async with Client(base_url="http://test") as client:
96
+ with pytest.raises(ACPError) as e:
97
+ async for _ in client.run_stream("Howdy!", agent=mock_run.agent_name):
98
+ raise AssertionError()
99
+ assert e.value.error == error
100
+
101
+
80
102
  @pytest.mark.asyncio
81
103
  async def test_run_status(httpx_mock: HTTPXMock) -> None:
82
104
  httpx_mock.add_response(url=f"http://test/runs/{mock_run.run_id}", method="GET", content=mock_run.model_dump_json())
File without changes
@@ -0,0 +1,31 @@
1
+ import asyncio
2
+ from collections.abc import AsyncGenerator
3
+ from contextlib import asynccontextmanager
4
+
5
+ import pytest
6
+ from acp_sdk.server import Server
7
+ from fastapi import FastAPI
8
+
9
+
10
+ @pytest.mark.asyncio
11
+ async def test_lifespan() -> None:
12
+ entry = False
13
+ exit = False
14
+
15
+ class TestServer(Server):
16
+ @asynccontextmanager
17
+ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None]:
18
+ nonlocal entry
19
+ nonlocal exit
20
+ entry = True
21
+ yield
22
+ exit = True
23
+
24
+ server = TestServer()
25
+ task = asyncio.create_task(server.serve())
26
+ await asyncio.sleep(1)
27
+ server.should_exit = True
28
+ await task
29
+
30
+ assert entry
31
+ assert exit
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes