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.
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/PKG-INFO +13 -11
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/README.md +1 -1
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/pyproject.toml +17 -11
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/pytest.ini +2 -1
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/client/client.py +1 -5
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/app.py +76 -44
- acp_sdk-0.10.0/src/acp_sdk/server/executor.py +198 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/server.py +9 -10
- acp_sdk-0.10.0/src/acp_sdk/server/session.py +24 -0
- acp_sdk-0.10.0/src/acp_sdk/server/store/__init__.py +4 -0
- acp_sdk-0.10.0/src/acp_sdk/server/store/memory_store.py +35 -0
- acp_sdk-0.10.0/src/acp_sdk/server/store/postgresql_store.py +69 -0
- acp_sdk-0.10.0/src/acp_sdk/server/store/redis_store.py +40 -0
- acp_sdk-0.10.0/src/acp_sdk/server/store/store.py +55 -0
- acp_sdk-0.10.0/src/acp_sdk/server/store/utils.py +5 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/telemetry.py +6 -2
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/utils.py +28 -4
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/fixtures/server.py +59 -5
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/test_suites/test_runs.py +1 -31
- acp_sdk-0.9.0/src/acp_sdk/server/bundle.py +0 -182
- acp_sdk-0.9.0/src/acp_sdk/server/session.py +0 -21
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/.gitignore +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/.python-version +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/docs/.gitignore +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/docs/Makefile +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/docs/conf.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/docs/index.rst +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/docs/make.bat +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/__init__.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/client/__init__.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/client/types.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/client/utils.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/instrumentation.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/models/__init__.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/models/errors.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/models/models.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/models/schemas.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/py.typed +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/__init__.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/agent.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/context.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/errors.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/logging.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/server/types.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/src/acp_sdk/version.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/conftest.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/__init__.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/config.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/fixtures/__init__.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/fixtures/client.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/test_suites/__init__.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/e2e/test_suites/test_discovery.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/unit/client/test_client.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/unit/client/test_utils.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/unit/models/__init__.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/unit/models/test_models.py +0 -0
- {acp_sdk-0.9.0 → acp_sdk-0.10.0}/tests/unit/server/__init__.py +0 -0
- {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.
|
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
|
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
|
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
|
19
|
-
Requires-Dist:
|
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
|
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
|
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.
|
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
|
12
|
-
"pydantic>=2.0
|
13
|
-
"httpx>=0.26
|
14
|
-
"httpx-sse>=0.4
|
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
|
17
|
-
"opentelemetry-exporter-otlp-proto-http>=1.31
|
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
|
20
|
-
"janus>=2.0
|
21
|
-
"cachetools>=5.5
|
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 = [
|
31
|
+
dev = [
|
32
|
+
"pytest-httpx>=0.35.0",
|
33
|
+
"pytest-postgresql>=7.0.2",
|
34
|
+
"pytest-redis>=3.1.3",
|
35
|
+
]
|
@@ -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
|
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
|
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.
|
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
|
-
|
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
|
-
|
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
|
-
|
82
|
-
|
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
|
90
|
-
|
91
|
-
if not
|
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
|
-
|
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 =
|
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
|
-
|
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
|
-
|
148
|
+
await run_store.set(run_data.key, run_data)
|
133
149
|
|
134
|
-
|
135
|
-
|
150
|
+
session.append(run_data.run.run_id)
|
151
|
+
await session_store.set(session.id, session)
|
136
152
|
|
137
|
-
headers = {Headers.RUN_ID: str(
|
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(
|
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
|
174
|
+
await wait_util_stop(run_data, run_store, ready=ready)
|
148
175
|
return JSONResponse(
|
149
176
|
headers=headers,
|
150
|
-
content=jsonable_encoder(
|
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(
|
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 =
|
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 =
|
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
|
-
|
201
|
+
run_data = await find_run_data(run_id)
|
174
202
|
|
175
|
-
if
|
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
|
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 {
|
209
|
+
detail=f"Run {run_id} is expecting resume of type {run_data.run.await_request.type}",
|
182
210
|
)
|
183
211
|
|
184
|
-
|
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(
|
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
|
193
|
-
return
|
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(
|
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
|
-
|
205
|
-
if
|
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 {
|
239
|
+
detail=f"Run in terminal status {run_data.run.status} can't be cancelled",
|
209
240
|
)
|
210
|
-
await
|
211
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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",
|
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
|