acp-sdk 0.9.0__tar.gz → 0.10.0__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 (58) hide show
  1. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/PKG-INFO +13 -11
  2. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/README.md +1 -1
  3. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/pyproject.toml +17 -11
  4. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/pytest.ini +2 -1
  5. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/client/client.py +1 -5
  6. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/app.py +76 -44
  7. acp_sdk-0.10.0/src/acp_sdk/server/executor.py +198 -0
  8. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/server.py +9 -10
  9. acp_sdk-0.10.0/src/acp_sdk/server/session.py +24 -0
  10. acp_sdk-0.10.0/src/acp_sdk/server/store/__init__.py +4 -0
  11. acp_sdk-0.10.0/src/acp_sdk/server/store/memory_store.py +35 -0
  12. acp_sdk-0.10.0/src/acp_sdk/server/store/postgresql_store.py +69 -0
  13. acp_sdk-0.10.0/src/acp_sdk/server/store/redis_store.py +40 -0
  14. acp_sdk-0.10.0/src/acp_sdk/server/store/store.py +55 -0
  15. acp_sdk-0.10.0/src/acp_sdk/server/store/utils.py +5 -0
  16. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/telemetry.py +6 -2
  17. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/utils.py +28 -4
  18. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/fixtures/server.py +59 -5
  19. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/test_suites/test_runs.py +1 -31
  20. acp_sdk-0.9.0/src/acp_sdk/server/bundle.py +0 -182
  21. acp_sdk-0.9.0/src/acp_sdk/server/session.py +0 -21
  22. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/.gitignore +0 -0
  23. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/.python-version +0 -0
  24. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/docs/.gitignore +0 -0
  25. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/docs/Makefile +0 -0
  26. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/docs/conf.py +0 -0
  27. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/docs/index.rst +0 -0
  28. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/docs/make.bat +0 -0
  29. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/__init__.py +0 -0
  30. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/client/__init__.py +0 -0
  31. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/client/types.py +0 -0
  32. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/client/utils.py +0 -0
  33. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/instrumentation.py +0 -0
  34. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/models/__init__.py +0 -0
  35. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/models/errors.py +0 -0
  36. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/models/models.py +0 -0
  37. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/models/schemas.py +0 -0
  38. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/py.typed +0 -0
  39. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/__init__.py +0 -0
  40. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/agent.py +0 -0
  41. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/context.py +0 -0
  42. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/errors.py +0 -0
  43. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/logging.py +0 -0
  44. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/types.py +0 -0
  45. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/version.py +0 -0
  46. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/conftest.py +0 -0
  47. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/__init__.py +0 -0
  48. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/config.py +0 -0
  49. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/fixtures/__init__.py +0 -0
  50. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/fixtures/client.py +0 -0
  51. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/test_suites/__init__.py +0 -0
  52. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/test_suites/test_discovery.py +0 -0
  53. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/unit/client/test_client.py +0 -0
  54. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/unit/client/test_utils.py +0 -0
  55. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/unit/models/__init__.py +0 -0
  56. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/unit/models/test_models.py +0 -0
  57. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/unit/server/__init__.py +0 -0
  58. {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/unit/server/test_server.py +0 -0
@@ -1,27 +1,29 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acp-sdk
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Agent Communication Protocol SDK
5
5
  Author: IBM Corp.
6
6
  Maintainer-email: Tomas Pilar <thomas7pilar@gmail.com>
7
7
  License-Expression: Apache-2.0
8
8
  Requires-Python: <4.0,>=3.11
9
- Requires-Dist: cachetools>=5.5.2
10
- Requires-Dist: fastapi[standard]>=0.115.8
11
- Requires-Dist: httpx-sse>=0.4.0
12
- Requires-Dist: httpx>=0.26.0
13
- Requires-Dist: janus>=2.0.0
14
- Requires-Dist: opentelemetry-api>=1.31.1
15
- Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.31.1
9
+ Requires-Dist: cachetools>=5.5
10
+ Requires-Dist: fastapi[standard]>=0.115
11
+ Requires-Dist: httpx-sse>=0.4
12
+ Requires-Dist: httpx>=0.26
13
+ Requires-Dist: janus>=2.0
14
+ Requires-Dist: opentelemetry-api>=1.31
15
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.31
16
16
  Requires-Dist: opentelemetry-instrumentation-fastapi>=0.52b1
17
17
  Requires-Dist: opentelemetry-instrumentation-httpx>=0.52b1
18
- Requires-Dist: opentelemetry-sdk>=1.31.1
19
- Requires-Dist: pydantic>=2.0.0
18
+ Requires-Dist: opentelemetry-sdk>=1.31
19
+ Requires-Dist: psycopg[binary]>=3.2
20
+ Requires-Dist: pydantic>=2.0
21
+ Requires-Dist: redis>=6.1
20
22
  Description-Content-Type: text/markdown
21
23
 
22
24
  # Agent Communication Protocol SDK for Python
23
25
 
24
- Agent Communication Protocol SDK for Python provides allows developers to serve and consume agents over the Agent Communication Protocol.
26
+ Agent Communication Protocol SDK for Python helps developers to serve and consume agents over the Agent Communication Protocol.
25
27
 
26
28
  ## Prerequisites
27
29
 
@@ -1,6 +1,6 @@
1
1
  # Agent Communication Protocol SDK for Python
2
2
 
3
- Agent Communication Protocol SDK for Python provides allows developers to serve and consume agents over the Agent Communication Protocol.
3
+ Agent Communication Protocol SDK for Python helps developers to serve and consume agents over the Agent Communication Protocol.
4
4
 
5
5
  ## Prerequisites
6
6
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acp-sdk"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  description = "Agent Communication Protocol SDK"
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
@@ -8,17 +8,19 @@ authors = [{ name = "IBM Corp." }]
8
8
  maintainers = [{ name = "Tomas Pilar", email = "thomas7pilar@gmail.com" }]
9
9
  requires-python = ">=3.11, <4.0"
10
10
  dependencies = [
11
- "opentelemetry-api>=1.31.1",
12
- "pydantic>=2.0.0",
13
- "httpx>=0.26.0",
14
- "httpx-sse>=0.4.0",
11
+ "opentelemetry-api>=1.31",
12
+ "pydantic>=2.0",
13
+ "httpx>=0.26",
14
+ "httpx-sse>=0.4",
15
15
  "opentelemetry-instrumentation-httpx>=0.52b1",
16
- "fastapi[standard]>=0.115.8",
17
- "opentelemetry-exporter-otlp-proto-http>=1.31.1",
16
+ "fastapi[standard]>=0.115",
17
+ "opentelemetry-exporter-otlp-proto-http>=1.31",
18
18
  "opentelemetry-instrumentation-fastapi>=0.52b1",
19
- "opentelemetry-sdk>=1.31.1",
20
- "janus>=2.0.0",
21
- "cachetools>=5.5.2",
19
+ "opentelemetry-sdk>=1.31",
20
+ "janus>=2.0",
21
+ "cachetools>=5.5",
22
+ "redis>=6.1",
23
+ "psycopg[binary]>=3.2",
22
24
  ]
23
25
 
24
26
  [build-system]
@@ -26,4 +28,8 @@ requires = ["hatchling"]
26
28
  build-backend = "hatchling.build"
27
29
 
28
30
  [dependency-groups]
29
- dev = ["pytest-httpx>=0.35.0"]
31
+ dev = [
32
+ "pytest-httpx>=0.35.0",
33
+ "pytest-postgresql>=7.0.2",
34
+ "pytest-redis>=3.1.3",
35
+ ]
@@ -2,4 +2,5 @@
2
2
  testpaths = tests/e2e
3
3
  python_files = test_*.py
4
4
  python_functions = test_*
5
- addopts = -v --strict-markers
5
+ addopts = -v --strict-markers
6
+ redis_datadir = /tmp
@@ -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
@@ -44,7 +43,6 @@ class Client:
44
43
  *,
45
44
  session_id: SessionId | None = None,
46
45
  client: httpx.AsyncClient | None = None,
47
- instrument: bool = True,
48
46
  auth: httpx._types.AuthTypes | None = None,
49
47
  params: httpx._types.QueryParamTypes | None = None,
50
48
  headers: httpx._types.HeaderTypes | None = None,
@@ -85,8 +83,6 @@ class Client:
85
83
  transport=transport,
86
84
  trust_env=trust_env,
87
85
  )
88
- if instrument:
89
- HTTPXClientInstrumentor.instrument_client(self._client)
90
86
 
91
87
  @property
92
88
  def client(self) -> httpx.AsyncClient:
@@ -108,7 +104,7 @@ class Client:
108
104
  async def session(self, session_id: SessionId | None = None) -> AsyncGenerator[Self]:
109
105
  session_id = session_id or uuid.uuid4()
110
106
  with get_tracer().start_as_current_span("session", attributes={"acp.session": str(session_id)}):
111
- yield Client(client=self._client, session_id=session_id, instrument=False)
107
+ yield Client(client=self._client, session_id=session_id)
112
108
 
113
109
  async def agents(self) -> AsyncIterator[Agent]:
114
110
  response = await self._client.get("/agents")
@@ -1,15 +1,15 @@
1
+ import asyncio
1
2
  from collections.abc import AsyncGenerator
2
3
  from concurrent.futures import ThreadPoolExecutor
3
4
  from contextlib import asynccontextmanager
4
- from datetime import datetime, timedelta
5
+ from datetime import timedelta
5
6
  from enum import Enum
6
7
 
7
- from cachetools import TTLCache
8
8
  from fastapi import Depends, FastAPI, HTTPException, status
9
9
  from fastapi.applications import AppType, Lifespan
10
10
  from fastapi.encoders import jsonable_encoder
11
+ from fastapi.middleware.cors import CORSMiddleware
11
12
  from fastapi.responses import JSONResponse, StreamingResponse
12
- from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
13
13
 
14
14
  from acp_sdk.models import (
15
15
  Agent as AgentModel,
@@ -28,12 +28,11 @@ from acp_sdk.models import (
28
28
  RunReadResponse,
29
29
  RunResumeRequest,
30
30
  RunResumeResponse,
31
- SessionId,
32
31
  )
33
32
  from acp_sdk.models.errors import ACPError
33
+ from acp_sdk.models.models import AwaitResume, RunStatus
34
34
  from acp_sdk.models.schemas import PingResponse
35
35
  from acp_sdk.server.agent import Agent
36
- from acp_sdk.server.bundle import RunBundle
37
36
  from acp_sdk.server.errors import (
38
37
  RequestValidationError,
39
38
  StarletteHTTPException,
@@ -42,8 +41,10 @@ from acp_sdk.server.errors import (
42
41
  http_exception_handler,
43
42
  validation_exception_handler,
44
43
  )
44
+ from acp_sdk.server.executor import CancelData, Executor, RunData
45
45
  from acp_sdk.server.session import Session
46
- from acp_sdk.server.utils import stream_sse
46
+ from acp_sdk.server.store import MemoryStore, Store
47
+ from acp_sdk.server.utils import stream_sse, wait_util_stop
47
48
 
48
49
 
49
50
  class Headers(str, Enum):
@@ -52,8 +53,7 @@ class Headers(str, Enum):
52
53
 
53
54
  def create_app(
54
55
  *agents: Agent,
55
- run_limit: int = 1000,
56
- run_ttl: timedelta = timedelta(hours=1),
56
+ store: Store | None = None,
57
57
  lifespan: Lifespan[AppType] | None = None,
58
58
  dependencies: list[Depends] | None = None,
59
59
  ) -> FastAPI:
@@ -75,22 +75,37 @@ def create_app(
75
75
  dependencies=dependencies,
76
76
  )
77
77
 
78
- FastAPIInstrumentor.instrument_app(app)
78
+ app.add_middleware(
79
+ CORSMiddleware,
80
+ allow_origins=["https://agentcommunicationprotocol.dev"],
81
+ allow_methods=["*"],
82
+ allow_headers=["*"],
83
+ allow_credentials=True,
84
+ )
79
85
 
80
86
  agents: dict[AgentName, Agent] = {agent.name: agent for agent in agents}
81
- runs: TTLCache[RunId, RunBundle] = TTLCache(maxsize=run_limit, ttl=run_ttl, timer=datetime.now)
82
- sessions: TTLCache[SessionId, Session] = TTLCache(maxsize=run_limit, ttl=run_ttl, timer=datetime.now)
87
+
88
+ store = store or MemoryStore(limit=1000, ttl=timedelta(hours=1))
89
+ run_store = store.as_store(model=RunData, prefix="run_")
90
+ run_cancel_store = store.as_store(model=CancelData, prefix="run_cancel_")
91
+ run_resume_store = store.as_store(model=AwaitResume, prefix="run_resume_")
92
+ session_store = store.as_store(model=Session, prefix="session_")
83
93
 
84
94
  app.exception_handler(ACPError)(acp_error_handler)
85
95
  app.exception_handler(StarletteHTTPException)(http_exception_handler)
86
96
  app.exception_handler(RequestValidationError)(validation_exception_handler)
87
97
  app.exception_handler(Exception)(catch_all_exception_handler)
88
98
 
89
- def find_run_bundle(run_id: RunId) -> RunBundle:
90
- bundle = runs.get(run_id)
91
- if not bundle:
99
+ async def find_run_data(run_id: RunId) -> RunData:
100
+ run_data = await run_store.get(run_id)
101
+ if not run_data:
92
102
  raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
93
- return bundle
103
+ if run_data.run.status.is_terminal:
104
+ return run_data
105
+ cancel_data = await run_cancel_store.get(run_data.key)
106
+ if cancel_data is not None:
107
+ run_data.run.status = RunStatus.CANCELLING
108
+ return run_data
94
109
 
95
110
  def find_agent(agent_name: AgentName) -> Agent:
96
111
  agent = agents.get(agent_name, None)
@@ -120,94 +135,111 @@ def create_app(
120
135
  async def create_run(request: RunCreateRequest) -> RunCreateResponse:
121
136
  agent = find_agent(request.agent_name)
122
137
 
123
- session = sessions.get(request.session_id, Session(id=request.session_id)) if request.session_id else Session()
138
+ session = (
139
+ (await session_store.get(request.session_id)) or Session(id=request.session_id)
140
+ if request.session_id
141
+ else Session()
142
+ )
124
143
  nonlocal executor
125
- bundle = RunBundle(
126
- agent=agent,
144
+ run_data = RunData(
127
145
  run=Run(agent_name=agent.name, session_id=session.id),
128
146
  input=request.input,
129
- history=list(session.history()),
130
- executor=executor,
131
147
  )
132
- session.append(bundle)
148
+ await run_store.set(run_data.key, run_data)
133
149
 
134
- runs[bundle.run.run_id] = bundle
135
- sessions[session.id] = session
150
+ session.append(run_data.run.run_id)
151
+ await session_store.set(session.id, session)
136
152
 
137
- headers = {Headers.RUN_ID: str(bundle.run.run_id)}
153
+ headers = {Headers.RUN_ID: str(run_data.run.run_id)}
154
+ ready = asyncio.Event()
155
+
156
+ Executor(
157
+ agent=agent,
158
+ run_data=run_data,
159
+ history=await session.history(run_store),
160
+ run_store=run_store,
161
+ cancel_store=run_cancel_store,
162
+ resume_store=run_resume_store,
163
+ executor=executor,
164
+ ).execute(wait=ready)
138
165
 
139
166
  match request.mode:
140
167
  case RunMode.STREAM:
141
168
  return StreamingResponse(
142
- stream_sse(bundle),
169
+ stream_sse(run_data, run_store, 0, ready=ready),
143
170
  headers=headers,
144
171
  media_type="text/event-stream",
145
172
  )
146
173
  case RunMode.SYNC:
147
- await bundle.join()
174
+ await wait_util_stop(run_data, run_store, ready=ready)
148
175
  return JSONResponse(
149
176
  headers=headers,
150
- content=jsonable_encoder(bundle.run),
177
+ content=jsonable_encoder(run_data.run),
151
178
  )
152
179
  case RunMode.ASYNC:
180
+ ready.set()
153
181
  return JSONResponse(
154
182
  status_code=status.HTTP_202_ACCEPTED,
155
183
  headers=headers,
156
- content=jsonable_encoder(bundle.run),
184
+ content=jsonable_encoder(run_data.run),
157
185
  )
158
186
  case _:
159
187
  raise NotImplementedError()
160
188
 
161
189
  @app.get("/runs/{run_id}")
162
190
  async def read_run(run_id: RunId) -> RunReadResponse:
163
- bundle = find_run_bundle(run_id)
191
+ bundle = await find_run_data(run_id)
164
192
  return bundle.run
165
193
 
166
194
  @app.get("/runs/{run_id}/events")
167
195
  async def list_run_events(run_id: RunId) -> RunEventsListResponse:
168
- bundle = find_run_bundle(run_id)
196
+ bundle = await find_run_data(run_id)
169
197
  return RunEventsListResponse(events=bundle.events)
170
198
 
171
199
  @app.post("/runs/{run_id}")
172
200
  async def resume_run(run_id: RunId, request: RunResumeRequest) -> RunResumeResponse:
173
- bundle = find_run_bundle(run_id)
201
+ run_data = await find_run_data(run_id)
174
202
 
175
- if bundle.run.await_request is None:
203
+ if run_data.run.await_request is None:
176
204
  raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Run {run_id} has no await request")
177
205
 
178
- if bundle.run.await_request.type != request.await_resume.type:
206
+ if run_data.run.await_request.type != request.await_resume.type:
179
207
  raise HTTPException(
180
208
  status_code=status.HTTP_403_FORBIDDEN,
181
- detail=f"Run {run_id} is expecting resume of type {bundle.run.await_request.type}",
209
+ detail=f"Run {run_id} is expecting resume of type {run_data.run.await_request.type}",
182
210
  )
183
211
 
184
- await bundle.resume(request.await_resume)
212
+ run_data.run.status = RunStatus.IN_PROGRESS
213
+ await run_store.set(run_data.key, run_data)
214
+ await run_resume_store.set(run_data.key, request.await_resume)
215
+
185
216
  match request.mode:
186
217
  case RunMode.STREAM:
187
218
  return StreamingResponse(
188
- stream_sse(bundle),
219
+ stream_sse(run_data, run_store, len(run_data.events)),
189
220
  media_type="text/event-stream",
190
221
  )
191
222
  case RunMode.SYNC:
192
- await bundle.join()
193
- return bundle.run
223
+ run_data = await wait_util_stop(run_data, run_store)
224
+ return run_data.run
194
225
  case RunMode.ASYNC:
195
226
  return JSONResponse(
196
227
  status_code=status.HTTP_202_ACCEPTED,
197
- content=jsonable_encoder(bundle.run),
228
+ content=jsonable_encoder(run_data.run),
198
229
  )
199
230
  case _:
200
231
  raise NotImplementedError()
201
232
 
202
233
  @app.post("/runs/{run_id}/cancel")
203
234
  async def cancel_run(run_id: RunId) -> RunCancelResponse:
204
- bundle = find_run_bundle(run_id)
205
- if bundle.run.status.is_terminal:
235
+ run_data = await find_run_data(run_id)
236
+ if run_data.run.status.is_terminal:
206
237
  raise HTTPException(
207
238
  status_code=status.HTTP_403_FORBIDDEN,
208
- detail=f"Run in terminal status {bundle.run.status} can't be cancelled",
239
+ detail=f"Run in terminal status {run_data.run.status} can't be cancelled",
209
240
  )
210
- await bundle.cancel()
211
- return JSONResponse(status_code=status.HTTP_202_ACCEPTED, content=jsonable_encoder(bundle.run))
241
+ await run_cancel_store.set(run_data.key, CancelData())
242
+ run_data.run.status = RunStatus.CANCELLING
243
+ return JSONResponse(status_code=status.HTTP_202_ACCEPTED, content=jsonable_encoder(run_data.run))
212
244
 
213
245
  return app
@@ -0,0 +1,198 @@
1
+ import asyncio
2
+ import logging
3
+ from collections.abc import AsyncIterator
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from datetime import datetime, timezone
6
+ from typing import Self
7
+
8
+ from pydantic import BaseModel, ValidationError
9
+
10
+ from acp_sdk.instrumentation import get_tracer
11
+ from acp_sdk.models import (
12
+ ACPError,
13
+ AnyModel,
14
+ AwaitRequest,
15
+ AwaitResume,
16
+ Error,
17
+ ErrorCode,
18
+ Event,
19
+ GenericEvent,
20
+ Message,
21
+ MessageCompletedEvent,
22
+ MessageCreatedEvent,
23
+ MessagePart,
24
+ MessagePartEvent,
25
+ Run,
26
+ RunAwaitingEvent,
27
+ RunCancelledEvent,
28
+ RunCompletedEvent,
29
+ RunCreatedEvent,
30
+ RunFailedEvent,
31
+ RunInProgressEvent,
32
+ RunStatus,
33
+ )
34
+ from acp_sdk.server.agent import Agent
35
+ from acp_sdk.server.logging import logger
36
+ from acp_sdk.server.store import Store
37
+
38
+
39
+ class RunData(BaseModel):
40
+ run: Run
41
+ input: list[Message]
42
+ events: list[Event] = []
43
+
44
+ @property
45
+ def key(self) -> str:
46
+ return str(self.run.run_id)
47
+
48
+ async def watch(self, store: Store[Self], *, ready: asyncio.Event | None = None) -> AsyncIterator[Self]:
49
+ async for data in store.watch(self.key, ready=ready):
50
+ if data is None:
51
+ raise RuntimeError("Missing data")
52
+ yield data
53
+ if data.run.status.is_terminal:
54
+ break
55
+
56
+
57
+ class CancelData(BaseModel):
58
+ pass
59
+
60
+
61
+ class Executor:
62
+ def __init__(
63
+ self,
64
+ *,
65
+ agent: Agent,
66
+ run_data: RunData,
67
+ history: list[Message],
68
+ executor: ThreadPoolExecutor,
69
+ run_store: Store[RunData],
70
+ cancel_store: Store[CancelData],
71
+ resume_store: Store[AwaitResume],
72
+ ) -> None:
73
+ self.agent = agent
74
+ self.history = history
75
+ self.run_data = run_data
76
+ self.executor = executor
77
+
78
+ self.run_store = run_store
79
+ self.cancel_store = cancel_store
80
+ self.resume_store = resume_store
81
+
82
+ self.logger = logging.LoggerAdapter(logger, {"run_id": str(run_data.run.run_id)})
83
+
84
+ def execute(self, *, wait: asyncio.Event) -> None:
85
+ self.task = asyncio.create_task(self._execute(self.run_data, executor=self.executor, wait=wait))
86
+ self.watcher = asyncio.create_task(self._watch_for_cancellation())
87
+
88
+ async def _push(self) -> None:
89
+ await self.run_store.set(self.run_data.run.run_id, self.run_data)
90
+
91
+ async def _emit(self, event: Event) -> None:
92
+ freeze = event.model_copy(deep=True)
93
+ self.run_data.events.append(freeze)
94
+ await self._push()
95
+
96
+ async def _await(self) -> AwaitResume:
97
+ async for resume in self.resume_store.watch(self.run_data.key):
98
+ if resume is not None:
99
+ await self.resume_store.set(self.run_data.key, None)
100
+ return resume
101
+
102
+ async def _watch_for_cancellation(self) -> None:
103
+ while not self.task.done():
104
+ try:
105
+ async for data in self.cancel_store.watch(self.run_data.key):
106
+ if data is not None:
107
+ self.task.cancel()
108
+ except Exception:
109
+ logger.warning("Cancellation watcher failed, restarting")
110
+
111
+ async def _execute(self, run_data: RunData, *, executor: ThreadPoolExecutor, wait: asyncio.Event) -> None:
112
+ with get_tracer().start_as_current_span("run"):
113
+ in_message = False
114
+
115
+ async def flush_message() -> None:
116
+ nonlocal in_message
117
+ if in_message:
118
+ message = run_data.run.output[-1]
119
+ message.completed_at = datetime.now(timezone.utc)
120
+ await self._emit(MessageCompletedEvent(message=message))
121
+ in_message = False
122
+
123
+ try:
124
+ await wait.wait()
125
+
126
+ await self._emit(RunCreatedEvent(run=run_data.run))
127
+
128
+ generator = self.agent.execute(
129
+ input=self.history + run_data.input, session_id=run_data.run.session_id, executor=executor
130
+ )
131
+ self.logger.info("Run started")
132
+
133
+ run_data.run.status = RunStatus.IN_PROGRESS
134
+ await self._emit(RunInProgressEvent(run=run_data.run))
135
+
136
+ await_resume = None
137
+ while True:
138
+ next = await generator.asend(await_resume)
139
+
140
+ if isinstance(next, (MessagePart, str)):
141
+ if isinstance(next, str):
142
+ next = MessagePart(content=next)
143
+ if not in_message:
144
+ run_data.run.output.append(Message(parts=[], completed_at=None))
145
+ in_message = True
146
+ await self._emit(MessageCreatedEvent(message=run_data.run.output[-1]))
147
+ run_data.run.output[-1].parts.append(next)
148
+ await self._emit(MessagePartEvent(part=next))
149
+ elif isinstance(next, Message):
150
+ await flush_message()
151
+ run_data.run.output.append(next)
152
+ await self._emit(MessageCreatedEvent(message=next))
153
+ for part in next.parts:
154
+ await self._emit(MessagePartEvent(part=part))
155
+ await self._emit(MessageCompletedEvent(message=next))
156
+ elif isinstance(next, AwaitRequest):
157
+ run_data.run.await_request = next
158
+ run_data.run.status = RunStatus.AWAITING
159
+ await self._emit(RunAwaitingEvent(run=run_data.run))
160
+ self.logger.info("Run awaited")
161
+ await_resume = await self._await()
162
+ run_data.run.status = RunStatus.IN_PROGRESS
163
+ await self._emit(RunInProgressEvent(run=run_data.run))
164
+ self.logger.info("Run resumed")
165
+ elif isinstance(next, Error):
166
+ raise ACPError(error=next)
167
+ elif isinstance(next, BaseException):
168
+ raise next
169
+ elif next is None:
170
+ await flush_message()
171
+ elif isinstance(next, BaseModel):
172
+ await self._emit(GenericEvent(generic=AnyModel(**next.model_dump())))
173
+ else:
174
+ try:
175
+ generic = AnyModel.model_validate(next)
176
+ await self._emit(GenericEvent(generic=generic))
177
+ except ValidationError:
178
+ raise TypeError("Invalid yield")
179
+ except StopAsyncIteration:
180
+ await flush_message()
181
+ run_data.run.status = RunStatus.COMPLETED
182
+ run_data.run.finished_at = datetime.now(timezone.utc)
183
+ await self._emit(RunCompletedEvent(run=run_data.run))
184
+ self.logger.info("Run completed")
185
+ except asyncio.CancelledError:
186
+ run_data.run.status = RunStatus.CANCELLED
187
+ run_data.run.finished_at = datetime.now(timezone.utc)
188
+ await self._emit(RunCancelledEvent(run=run_data.run))
189
+ self.logger.info("Run cancelled")
190
+ except Exception as e:
191
+ if isinstance(e, ACPError):
192
+ run_data.run.error = e.error
193
+ else:
194
+ run_data.run.error = Error(code=ErrorCode.SERVER_ERROR, message=str(e))
195
+ run_data.run.status = RunStatus.FAILED
196
+ run_data.run.finished_at = datetime.now(timezone.utc)
197
+ await self._emit(RunFailedEvent(run=run_data.run))
198
+ self.logger.exception("Run failed")
@@ -2,7 +2,6 @@ import asyncio
2
2
  import os
3
3
  from collections.abc import AsyncGenerator, Awaitable
4
4
  from contextlib import asynccontextmanager
5
- from datetime import timedelta
6
5
  from typing import Any, Callable
7
6
 
8
7
  import requests
@@ -16,6 +15,7 @@ from acp_sdk.server.agent import agent as agent_decorator
16
15
  from acp_sdk.server.app import create_app
17
16
  from acp_sdk.server.logging import configure_logger as configure_logger_func
18
17
  from acp_sdk.server.logging import logger
18
+ from acp_sdk.server.store import Store
19
19
  from acp_sdk.server.telemetry import configure_telemetry as configure_telemetry_func
20
20
  from acp_sdk.server.utils import async_request_with_retry
21
21
 
@@ -54,8 +54,7 @@ class Server:
54
54
  configure_logger: bool = True,
55
55
  configure_telemetry: bool = False,
56
56
  self_registration: bool = True,
57
- run_limit: int = 1000,
58
- run_ttl: timedelta = timedelta(hours=1),
57
+ store: Store | None = None,
59
58
  host: str = "127.0.0.1",
60
59
  port: int = 8000,
61
60
  uds: str | None = None,
@@ -118,13 +117,15 @@ class Server:
118
117
 
119
118
  import uvicorn
120
119
 
120
+ app = create_app(*self.agents, lifespan=self.lifespan, store=store)
121
+
121
122
  if configure_logger:
122
123
  configure_logger_func()
123
124
  if configure_telemetry:
124
- configure_telemetry_func()
125
+ configure_telemetry_func(app)
125
126
 
126
127
  config = uvicorn.Config(
127
- create_app(*self.agents, lifespan=self.lifespan, run_limit=run_limit, run_ttl=run_ttl),
128
+ app,
128
129
  host,
129
130
  port,
130
131
  uds,
@@ -182,8 +183,7 @@ class Server:
182
183
  configure_logger: bool = True,
183
184
  configure_telemetry: bool = False,
184
185
  self_registration: bool = True,
185
- run_limit: int = 1000,
186
- run_ttl: timedelta = timedelta(hours=1),
186
+ store: Store | None = None,
187
187
  host: str = "127.0.0.1",
188
188
  port: int = 8000,
189
189
  uds: str | None = None,
@@ -241,8 +241,7 @@ class Server:
241
241
  configure_logger=configure_logger,
242
242
  configure_telemetry=configure_telemetry,
243
243
  self_registration=self_registration,
244
- run_limit=run_limit,
245
- run_ttl=run_ttl,
244
+ store=store,
246
245
  host=host,
247
246
  port=port,
248
247
  uds=uds,
@@ -309,7 +308,7 @@ class Server:
309
308
 
310
309
  async def _register_agent(self) -> None:
311
310
  """If not in PRODUCTION mode, register agent to the beeai platform and provide missing env variables"""
312
- if os.getenv("PRODUCTION_MODE", False):
311
+ if os.getenv("PRODUCTION_MODE", "").lower() in ["true", "1"]:
313
312
  logger.debug("Agent is not automatically registered in the production mode.")
314
313
  return
315
314
 
@@ -0,0 +1,24 @@
1
+ import uuid
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from acp_sdk.models import Message, RunId, RunStatus, SessionId
6
+ from acp_sdk.server.executor import RunData
7
+ from acp_sdk.server.store import Store
8
+
9
+
10
+ class Session(BaseModel):
11
+ id: SessionId = Field(default_factory=uuid.uuid4)
12
+ runs: list[RunId] = []
13
+
14
+ def append(self, run_id: RunId) -> None:
15
+ self.runs.append(run_id)
16
+
17
+ async def history(self, store: Store[RunData]) -> list[Message]:
18
+ history = []
19
+ for run_id in self.runs:
20
+ run_data = await store.get(run_id)
21
+ if run_data is not None and run_data.run.status == RunStatus.COMPLETED:
22
+ history.extend(run_data.input)
23
+ history.extend(run_data.run.output)
24
+ return history
@@ -0,0 +1,4 @@
1
+ from acp_sdk.server.store.memory_store import MemoryStore as MemoryStore
2
+ from acp_sdk.server.store.postgresql_store import PostgreSQLStore as PostgreSQLStore
3
+ from acp_sdk.server.store.redis_store import RedisStore as RedisStore
4
+ from acp_sdk.server.store.store import Store as Store