acp-sdk 1.0.0rc2__py3-none-any.whl → 1.0.0rc4__py3-none-any.whl

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/__init__.py CHANGED
@@ -0,0 +1 @@
1
+ from acp_sdk.models import * # noqa: F403
acp_sdk/client/client.py CHANGED
@@ -8,11 +8,13 @@ from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
8
8
  from pydantic import TypeAdapter
9
9
 
10
10
  from acp_sdk.models import (
11
+ ACPError,
11
12
  Agent,
12
13
  AgentName,
13
14
  AgentReadResponse,
14
15
  AgentsListResponse,
15
16
  AwaitResume,
17
+ Error,
16
18
  Message,
17
19
  Run,
18
20
  RunCancelResponse,
@@ -32,7 +34,7 @@ class Client:
32
34
 
33
35
  self._client = self._init_client(client)
34
36
 
35
- def _init_client(self, client: httpx.AsyncClient | None = None) -> Self:
37
+ def _init_client(self, client: httpx.AsyncClient | None = None) -> httpx.AsyncClient:
36
38
  client = client or httpx.AsyncClient(base_url=self.base_url)
37
39
  HTTPXClientInstrumentor.instrument_client(client)
38
40
  return client
@@ -51,11 +53,13 @@ class Client:
51
53
 
52
54
  async def agents(self) -> AsyncIterator[Agent]:
53
55
  response = await self._client.get("/agents")
56
+ self._raise_error(response)
54
57
  for agent in AgentsListResponse.model_validate(response.json()).agents:
55
58
  yield agent
56
59
 
57
60
  async def agent(self, *, name: AgentName) -> Agent:
58
61
  response = await self._client.get(f"/agents/{name}")
62
+ self._raise_error(response)
59
63
  return AgentReadResponse.model_validate(response.json())
60
64
 
61
65
  async def run_sync(self, *, agent: AgentName, input: Message) -> Run:
@@ -63,6 +67,7 @@ class Client:
63
67
  "/runs",
64
68
  json=RunCreateRequest(agent_name=agent, input=input, mode=RunMode.SYNC).model_dump(),
65
69
  )
70
+ self._raise_error(response)
66
71
  return RunCreateResponse.model_validate(response.json())
67
72
 
68
73
  async def run_async(self, *, agent: AgentName, input: Message) -> Run:
@@ -70,6 +75,7 @@ class Client:
70
75
  "/runs",
71
76
  json=RunCreateRequest(agent_name=agent, input=input, mode=RunMode.ASYNC).model_dump(),
72
77
  )
78
+ self._raise_error(response)
73
79
  return RunCreateResponse.model_validate(response.json())
74
80
 
75
81
  async def run_stream(self, *, agent: AgentName, input: Message) -> AsyncIterator[RunEvent]:
@@ -84,10 +90,12 @@ class Client:
84
90
 
85
91
  async def run_status(self, *, run_id: RunId) -> Run:
86
92
  response = await self._client.get(f"/runs/{run_id}")
93
+ self._raise_error(response)
87
94
  return Run.model_validate(response.json())
88
95
 
89
96
  async def run_cancel(self, *, run_id: RunId) -> Run:
90
97
  response = await self._client.post(f"/runs/{run_id}/cancel")
98
+ self._raise_error(response)
91
99
  return RunCancelResponse.model_validate(response.json())
92
100
 
93
101
  async def run_resume_sync(self, *, run_id: RunId, await_: AwaitResume) -> Run:
@@ -95,6 +103,7 @@ class Client:
95
103
  f"/runs/{run_id}",
96
104
  json=RunResumeRequest(await_=await_, mode=RunMode.SYNC).model_dump(),
97
105
  )
106
+ self._raise_error(response)
98
107
  return RunResumeResponse.model_validate(response.json())
99
108
 
100
109
  async def run_resume_async(self, *, run_id: RunId, await_: AwaitResume) -> Run:
@@ -102,6 +111,7 @@ class Client:
102
111
  f"/runs/{run_id}",
103
112
  json=RunResumeRequest(await_=await_, mode=RunMode.ASYNC).model_dump(),
104
113
  )
114
+ self._raise_error(response)
105
115
  return RunResumeResponse.model_validate(response.json())
106
116
 
107
117
  async def run_resume_stream(self, *, run_id: RunId, await_: AwaitResume) -> AsyncIterator[RunEvent]:
@@ -121,3 +131,9 @@ class Client:
121
131
  async for event in event_source.aiter_sse():
122
132
  event = TypeAdapter(RunEvent).validate_json(event.data)
123
133
  yield event
134
+
135
+ def _raise_error(self, response: httpx.Response) -> None:
136
+ try:
137
+ response.raise_for_status()
138
+ except httpx.HTTPError:
139
+ raise ACPError(Error.model_validate(response.json()))
@@ -0,0 +1,3 @@
1
+ from acp_sdk.models.errors import * # noqa: F403
2
+ from acp_sdk.models.models import * # noqa: F403
3
+ from acp_sdk.models.schemas import * # noqa: F403
@@ -0,0 +1,23 @@
1
+ from enum import Enum
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class ErrorCode(str, Enum):
7
+ SERVER_ERROR = "server_error"
8
+ INVALID_INPUT = "invalid_input"
9
+ NOT_FOUND = "not_found"
10
+
11
+
12
+ class Error(BaseModel):
13
+ code: ErrorCode
14
+ message: str
15
+
16
+
17
+ class ACPError(Exception):
18
+ def __init__(self, error: Error) -> None:
19
+ super().__init__()
20
+ self.error = error
21
+
22
+ def __str__(self) -> str:
23
+ return str(self.error.message)
@@ -5,10 +5,11 @@ from typing import Any, Literal, Union
5
5
 
6
6
  from pydantic import AnyUrl, BaseModel, ConfigDict, Field, RootModel
7
7
 
8
+ from acp_sdk.models.errors import Error
8
9
 
9
- class ACPError(BaseModel):
10
- code: str
11
- message: str
10
+
11
+ class Metadata(BaseModel):
12
+ model_config = ConfigDict(extra="allow")
12
13
 
13
14
 
14
15
  class AnyModel(BaseModel):
@@ -97,7 +98,7 @@ class Run(BaseModel):
97
98
  status: RunStatus = RunStatus.CREATED
98
99
  await_: Await | None = Field(None, alias="await")
99
100
  output: Message | None = None
100
- error: ACPError | None = None
101
+ error: Error | None = None
101
102
 
102
103
  model_config = ConfigDict(populate_by_name=True)
103
104
 
@@ -174,42 +175,7 @@ RunEvent = Union[
174
175
  ]
175
176
 
176
177
 
177
- class RunCreateRequest(BaseModel):
178
- agent_name: AgentName
179
- session_id: SessionId | None = None
180
- input: Message
181
- mode: RunMode = RunMode.SYNC
182
-
183
-
184
- class RunCreateResponse(Run):
185
- pass
186
-
187
-
188
- class RunResumeRequest(BaseModel):
189
- await_: AwaitResume = Field(alias="await")
190
- mode: RunMode
191
-
192
-
193
- class RunResumeResponse(Run):
194
- pass
195
-
196
-
197
- class RunReadResponse(Run):
198
- pass
199
-
200
-
201
- class RunCancelResponse(Run):
202
- pass
203
-
204
-
205
178
  class Agent(BaseModel):
206
179
  name: str
207
180
  description: str | None = None
208
-
209
-
210
- class AgentsListResponse(BaseModel):
211
- agents: list[Agent]
212
-
213
-
214
- class AgentReadResponse(Agent):
215
- pass
181
+ metadata: Metadata = Metadata()
@@ -0,0 +1,39 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from acp_sdk.models.models import Agent, AgentName, AwaitResume, Message, Run, RunMode, SessionId
4
+
5
+
6
+ class AgentsListResponse(BaseModel):
7
+ agents: list[Agent]
8
+
9
+
10
+ class AgentReadResponse(Agent):
11
+ pass
12
+
13
+
14
+ class RunCreateRequest(BaseModel):
15
+ agent_name: AgentName
16
+ session_id: SessionId | None = None
17
+ input: Message
18
+ mode: RunMode = RunMode.SYNC
19
+
20
+
21
+ class RunCreateResponse(Run):
22
+ pass
23
+
24
+
25
+ class RunResumeRequest(BaseModel):
26
+ await_: AwaitResume = Field(alias="await")
27
+ mode: RunMode
28
+
29
+
30
+ class RunResumeResponse(Run):
31
+ pass
32
+
33
+
34
+ class RunReadResponse(Run):
35
+ pass
36
+
37
+
38
+ class RunCancelResponse(Run):
39
+ pass
@@ -1,2 +1,8 @@
1
1
  from acp_sdk.server.agent import Agent as Agent
2
- from acp_sdk.server.server import create_app as create_app
2
+ from acp_sdk.server.agent import SyncAgent as SyncAgent
3
+ from acp_sdk.server.app import create_app as create_app
4
+ from acp_sdk.server.context import Context as Context
5
+ from acp_sdk.server.context import SyncContext as SyncContext
6
+ from acp_sdk.server.server import Server as Server
7
+ from acp_sdk.server.types import RunYield as RunYield
8
+ from acp_sdk.server.types import RunYieldResume as RunYieldResume
acp_sdk/server/agent.py CHANGED
@@ -1,14 +1,18 @@
1
1
  import abc
2
+ import asyncio
2
3
  from collections.abc import AsyncGenerator
4
+ from concurrent.futures import ThreadPoolExecutor
5
+
6
+ import janus
3
7
 
4
8
  from acp_sdk.models import (
5
9
  AgentName,
6
- Await,
7
- AwaitResume,
8
10
  Message,
9
11
  SessionId,
10
12
  )
11
- from acp_sdk.server.context import Context
13
+ from acp_sdk.models.models import Metadata
14
+ from acp_sdk.server.context import Context, SyncContext
15
+ from acp_sdk.server.types import RunYield, RunYieldResume
12
16
 
13
17
 
14
18
  class Agent(abc.ABC):
@@ -20,11 +24,51 @@ class Agent(abc.ABC):
20
24
  def description(self) -> str:
21
25
  return ""
22
26
 
27
+ @property
28
+ def metadata(self) -> Metadata:
29
+ return Metadata()
30
+
23
31
  @abc.abstractmethod
24
- def run(self, input: Message, *, context: Context) -> AsyncGenerator[Message | Await, AwaitResume]:
32
+ def run(
33
+ self, input: Message, context: Context, executor: ThreadPoolExecutor
34
+ ) -> AsyncGenerator[RunYield, RunYieldResume]:
25
35
  pass
26
36
 
27
37
  async def session(self, session_id: SessionId | None) -> SessionId | None:
28
38
  if session_id:
29
39
  raise NotImplementedError()
30
40
  return None
41
+
42
+
43
+ class SyncAgent(Agent):
44
+ @abc.abstractmethod
45
+ def run_sync(self, input: Message, context: SyncContext, executor: ThreadPoolExecutor) -> RunYield | None:
46
+ pass
47
+
48
+ async def run(
49
+ self, input: Message, context: Context, executor: ThreadPoolExecutor
50
+ ) -> AsyncGenerator[RunYield, RunYieldResume]:
51
+ yield_queue: janus.Queue[RunYield] = janus.Queue()
52
+ yield_resume_queue: janus.Queue[RunYieldResume] = janus.Queue()
53
+
54
+ run_future = asyncio.get_running_loop().run_in_executor(
55
+ executor,
56
+ self.run_sync,
57
+ input,
58
+ SyncContext(
59
+ session_id=context.session_id,
60
+ yield_queue=yield_queue.sync_q,
61
+ yield_resume_queue=yield_resume_queue.sync_q,
62
+ ),
63
+ executor,
64
+ )
65
+
66
+ while True:
67
+ yield_task = asyncio.create_task(yield_queue.async_q.get())
68
+ done, _ = await asyncio.wait([yield_task, run_future], return_when=asyncio.FIRST_COMPLETED)
69
+ if yield_task in done:
70
+ resume = yield await yield_task
71
+ await yield_resume_queue.async_q.put(resume)
72
+ if run_future in done:
73
+ yield await run_future
74
+ break
acp_sdk/server/app.py ADDED
@@ -0,0 +1,161 @@
1
+ import asyncio
2
+ from collections.abc import AsyncGenerator
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from contextlib import asynccontextmanager
5
+
6
+ from fastapi import FastAPI, HTTPException, status
7
+ from fastapi.responses import JSONResponse, StreamingResponse
8
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
9
+
10
+ from acp_sdk.models import (
11
+ Agent as AgentModel,
12
+ )
13
+ from acp_sdk.models import (
14
+ AgentName,
15
+ AgentReadResponse,
16
+ AgentsListResponse,
17
+ Run,
18
+ RunCancelResponse,
19
+ RunCreateRequest,
20
+ RunCreateResponse,
21
+ RunId,
22
+ RunMode,
23
+ RunReadResponse,
24
+ RunResumeRequest,
25
+ RunResumeResponse,
26
+ RunStatus,
27
+ )
28
+ from acp_sdk.models.errors import ACPError
29
+ from acp_sdk.server.agent import Agent
30
+ from acp_sdk.server.bundle import RunBundle
31
+ from acp_sdk.server.errors import (
32
+ RequestValidationError,
33
+ StarletteHTTPException,
34
+ acp_error_handler,
35
+ catch_all_exception_handler,
36
+ http_exception_handler,
37
+ validation_exception_handler,
38
+ )
39
+ from acp_sdk.server.utils import stream_sse
40
+
41
+
42
+ def create_app(*agents: Agent) -> FastAPI:
43
+ executor: ThreadPoolExecutor
44
+
45
+ @asynccontextmanager
46
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
47
+ nonlocal executor
48
+ with ThreadPoolExecutor(max_workers=5) as exec:
49
+ executor = exec
50
+ yield
51
+
52
+ app = FastAPI(lifespan=lifespan)
53
+
54
+ FastAPIInstrumentor.instrument_app(app)
55
+
56
+ agents: dict[AgentName, Agent] = {agent.name: agent for agent in agents}
57
+ runs: dict[RunId, RunBundle] = {}
58
+
59
+ app.exception_handler(ACPError)(acp_error_handler)
60
+ app.exception_handler(StarletteHTTPException)(http_exception_handler)
61
+ app.exception_handler(RequestValidationError)(validation_exception_handler)
62
+ app.exception_handler(Exception)(catch_all_exception_handler)
63
+
64
+ def find_run_bundle(run_id: RunId) -> RunBundle:
65
+ bundle = runs.get(run_id)
66
+ if not bundle:
67
+ raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
68
+ return bundle
69
+
70
+ def find_agent(agent_name: AgentName) -> Agent:
71
+ agent = agents.get(agent_name, None)
72
+ if not agent:
73
+ raise HTTPException(status_code=404, detail=f"Agent {agent_name} not found")
74
+ return agent
75
+
76
+ @app.get("/agents")
77
+ async def list_agents() -> AgentsListResponse:
78
+ return AgentsListResponse(
79
+ agents=[
80
+ AgentModel(name=agent.name, description=agent.description, metadata=agent.metadata)
81
+ for agent in agents.values()
82
+ ]
83
+ )
84
+
85
+ @app.get("/agents/{name}")
86
+ async def read_agent(name: AgentName) -> AgentReadResponse:
87
+ agent = find_agent(name)
88
+ return AgentModel(name=agent.name, description=agent.description, metadata=agent.metadata)
89
+
90
+ @app.post("/runs")
91
+ async def create_run(request: RunCreateRequest) -> RunCreateResponse:
92
+ agent = find_agent(request.agent_name)
93
+ bundle = RunBundle(
94
+ agent=agent,
95
+ run=Run(
96
+ agent_name=agent.name,
97
+ session_id=request.session_id,
98
+ ),
99
+ )
100
+
101
+ nonlocal executor
102
+ bundle.task = asyncio.create_task(bundle.execute(request.input, executor=executor))
103
+ runs[bundle.run.run_id] = bundle
104
+
105
+ match request.mode:
106
+ case RunMode.STREAM:
107
+ return StreamingResponse(
108
+ stream_sse(bundle),
109
+ media_type="text/event-stream",
110
+ )
111
+ case RunMode.SYNC:
112
+ await bundle.join()
113
+ return bundle.run
114
+ case RunMode.ASYNC:
115
+ return JSONResponse(
116
+ status_code=status.HTTP_202_ACCEPTED,
117
+ content=bundle.run.model_dump(),
118
+ )
119
+ case _:
120
+ raise NotImplementedError()
121
+
122
+ @app.get("/runs/{run_id}")
123
+ async def read_run(run_id: RunId) -> RunReadResponse:
124
+ bundle = find_run_bundle(run_id)
125
+ return bundle.run
126
+
127
+ @app.post("/runs/{run_id}")
128
+ async def resume_run(run_id: RunId, request: RunResumeRequest) -> RunResumeResponse:
129
+ bundle = find_run_bundle(run_id)
130
+ bundle.stream_queue = asyncio.Queue() # TODO improve
131
+ await bundle.await_queue.put(request.await_)
132
+ match request.mode:
133
+ case RunMode.STREAM:
134
+ return StreamingResponse(
135
+ stream_sse(bundle),
136
+ media_type="text/event-stream",
137
+ )
138
+ case RunMode.SYNC:
139
+ await bundle.join()
140
+ return bundle.run
141
+ case RunMode.ASYNC:
142
+ return JSONResponse(
143
+ status_code=status.HTTP_202_ACCEPTED,
144
+ content=bundle.run.model_dump(),
145
+ )
146
+ case _:
147
+ raise NotImplementedError()
148
+
149
+ @app.post("/runs/{run_id}/cancel")
150
+ async def cancel_run(run_id: RunId) -> RunCancelResponse:
151
+ bundle = find_run_bundle(run_id)
152
+ if bundle.run.status.is_terminal:
153
+ raise HTTPException(
154
+ status_code=403,
155
+ detail=f"Run with terminal status {bundle.run.status} can't be cancelled",
156
+ )
157
+ bundle.task.cancel()
158
+ bundle.run.status = RunStatus.CANCELLING
159
+ return JSONResponse(status_code=status.HTTP_202_ACCEPTED, content=bundle.run.model_dump())
160
+
161
+ return app
acp_sdk/server/bundle.py CHANGED
@@ -1,12 +1,12 @@
1
1
  import asyncio
2
2
  import logging
3
3
  from collections.abc import AsyncGenerator
4
+ from concurrent.futures import ThreadPoolExecutor
4
5
 
5
6
  from opentelemetry import trace
6
7
  from pydantic import ValidationError
7
8
 
8
9
  from acp_sdk.models import (
9
- ACPError,
10
10
  AnyModel,
11
11
  Await,
12
12
  AwaitEvent,
@@ -14,6 +14,7 @@ from acp_sdk.models import (
14
14
  CancelledEvent,
15
15
  CompletedEvent,
16
16
  CreatedEvent,
17
+ Error,
17
18
  FailedEvent,
18
19
  GenericEvent,
19
20
  InProgressEvent,
@@ -23,10 +24,10 @@ from acp_sdk.models import (
23
24
  RunEvent,
24
25
  RunStatus,
25
26
  )
27
+ from acp_sdk.models.errors import ErrorCode
26
28
  from acp_sdk.server.agent import Agent
27
29
  from acp_sdk.server.context import Context
28
-
29
- logger = logging.getLogger("uvicorn.error")
30
+ from acp_sdk.server.logging import logger
30
31
 
31
32
 
32
33
  class RunBundle:
@@ -68,7 +69,7 @@ class RunBundle:
68
69
  async def join(self) -> None:
69
70
  await self.await_or_terminate_event.wait()
70
71
 
71
- async def execute(self, input: Message) -> None:
72
+ async def execute(self, input: Message, *, executor: ThreadPoolExecutor) -> None:
72
73
  with trace.get_tracer(__name__).start_as_current_span("execute"):
73
74
  run_logger = logging.LoggerAdapter(logger, {"run_id": self.run.run_id})
74
75
 
@@ -77,7 +78,9 @@ class RunBundle:
77
78
  self.run.session_id = await self.agent.session(self.run.session_id)
78
79
  run_logger.info("Session loaded")
79
80
 
80
- generator = self.agent.run(input=input, context=Context(session_id=self.run.session_id))
81
+ generator = self.agent.run(
82
+ input=input, context=Context(session_id=self.run.session_id), executor=executor
83
+ )
81
84
  run_logger.info("Run started")
82
85
 
83
86
  self.run.status = RunStatus.IN_PROGRESS
@@ -122,7 +125,7 @@ class RunBundle:
122
125
  await self.emit(CancelledEvent(run=self.run))
123
126
  run_logger.info("Run cancelled")
124
127
  except Exception as e:
125
- self.run.error = ACPError(code="unspecified", message=str(e))
128
+ self.run.error = Error(code=ErrorCode.SERVER_ERROR, message=str(e))
126
129
  self.run.status = RunStatus.FAILED
127
130
  await self.emit(FailedEvent(run=self.run))
128
131
  run_logger.exception("Run failed")
acp_sdk/server/context.py CHANGED
@@ -1,6 +1,26 @@
1
+ import janus
2
+
1
3
  from acp_sdk.models import SessionId
4
+ from acp_sdk.server.types import RunYield, RunYieldResume
2
5
 
3
6
 
4
7
  class Context:
5
8
  def __init__(self, *, session_id: SessionId | None = None) -> None:
6
9
  self.session_id = session_id
10
+
11
+
12
+ class SyncContext(Context):
13
+ def __init__(
14
+ self,
15
+ *,
16
+ session_id: SessionId | None = None,
17
+ yield_queue: janus.SyncQueue[RunYield],
18
+ yield_resume_queue: janus.SyncQueue[RunYieldResume],
19
+ ) -> None:
20
+ super().__init__(session_id=session_id)
21
+ self._yield_queue = yield_queue
22
+ self._yield_resume_queue = yield_resume_queue
23
+
24
+ def yield_(self, data: RunYield) -> RunYieldResume:
25
+ self._yield_queue.put(data)
26
+ return self._yield_resume_queue.get()
@@ -0,0 +1,54 @@
1
+ from fastapi import Request, status
2
+ from fastapi.exceptions import RequestValidationError
3
+ from fastapi.responses import JSONResponse
4
+ from starlette.exceptions import HTTPException as StarletteHTTPException
5
+
6
+ from acp_sdk.models import Error, ErrorCode
7
+ from acp_sdk.models.errors import ACPError
8
+ from acp_sdk.server.logging import logger
9
+
10
+
11
+ def error_code_to_status_code(error_code: ErrorCode) -> int:
12
+ match error_code:
13
+ case ErrorCode.NOT_FOUND:
14
+ return status.HTTP_404_NOT_FOUND
15
+ case ErrorCode.INVALID_INPUT:
16
+ return status.HTTP_422_UNPROCESSABLE_ENTITY
17
+ case _:
18
+ return status.HTTP_500_INTERNAL_SERVER_ERROR
19
+
20
+
21
+ def status_code_to_error_code(status_code: int) -> ErrorCode:
22
+ match status_code:
23
+ case status.HTTP_400_BAD_REQUEST:
24
+ return ErrorCode.INVALID_INPUT
25
+ case status.HTTP_404_NOT_FOUND:
26
+ return ErrorCode.NOT_FOUND
27
+ case status.HTTP_422_UNPROCESSABLE_ENTITY:
28
+ return ErrorCode.INVALID_INPUT
29
+ case _:
30
+ return ErrorCode.SERVER_ERROR
31
+
32
+
33
+ async def acp_error_handler(request: Request, exc: ACPError, *, status_code: int | None = None) -> JSONResponse:
34
+ error = exc.error
35
+ return JSONResponse(status_code=status_code or error_code_to_status_code(error.code), content=error.model_dump())
36
+
37
+
38
+ async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
39
+ return await acp_error_handler(
40
+ request,
41
+ ACPError(Error(code=status_code_to_error_code(exc.status_code), message=exc.detail)),
42
+ status_code=exc.status_code,
43
+ )
44
+
45
+
46
+ async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
47
+ return await acp_error_handler(request, ACPError(Error(code=ErrorCode.INVALID_INPUT, message=str(exc))))
48
+
49
+
50
+ async def catch_all_exception_handler(request: Request, exc: Exception) -> JSONResponse:
51
+ logger.error(exc)
52
+ return await acp_error_handler(
53
+ request, ACPError(Error(code=ErrorCode.SERVER_ERROR, message="An unexpected error occurred"))
54
+ )
@@ -0,0 +1,16 @@
1
+ import logging
2
+
3
+ from uvicorn.logging import DefaultFormatter
4
+
5
+ logger = logging.getLogger("acp")
6
+
7
+
8
+ def configure_logger() -> None:
9
+ """Utility that configures the root logger"""
10
+ root_logger = logging.getLogger()
11
+
12
+ handler = logging.StreamHandler()
13
+ handler.setFormatter(DefaultFormatter(fmt="%(levelprefix)s %(message)s"))
14
+
15
+ root_logger.addHandler(handler)
16
+ root_logger.setLevel(logging.INFO)
acp_sdk/server/server.py CHANGED
@@ -1,133 +1,110 @@
1
- import asyncio
2
-
3
- from fastapi import FastAPI, HTTPException, status
4
- from fastapi.responses import JSONResponse, StreamingResponse
5
- from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
6
-
7
- from acp_sdk.models import (
8
- Agent as AgentModel,
9
- )
10
- from acp_sdk.models import (
11
- AgentName,
12
- AgentReadResponse,
13
- AgentsListResponse,
14
- Run,
15
- RunCancelResponse,
16
- RunCreateRequest,
17
- RunCreateResponse,
18
- RunId,
19
- RunMode,
20
- RunReadResponse,
21
- RunResumeRequest,
22
- RunResumeResponse,
23
- RunStatus,
24
- )
25
- from acp_sdk.server.agent import Agent
26
- from acp_sdk.server.bundle import RunBundle
27
- from acp_sdk.server.telemetry import configure_telemetry
28
- from acp_sdk.server.utils import stream_sse
29
-
30
-
31
- def create_app(*agents: Agent) -> FastAPI:
32
- app = FastAPI(title="acp-agents")
33
-
34
- configure_telemetry()
35
- FastAPIInstrumentor.instrument_app(app)
36
-
37
- agents: dict[AgentName, Agent] = {agent.name: agent for agent in agents}
38
- runs: dict[RunId, RunBundle] = {}
39
-
40
- def find_run_bundle(run_id: RunId) -> RunBundle:
41
- bundle = runs.get(run_id)
42
- if not bundle:
43
- raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
44
- return bundle
45
-
46
- def find_agent(agent_name: AgentName) -> Agent:
47
- agent = agents.get(agent_name, None)
48
- if not agent:
49
- raise HTTPException(status_code=404, detail=f"Agent {agent_name} not found")
50
- return agent
51
-
52
- @app.get("/agents")
53
- async def list_agents() -> AgentsListResponse:
54
- return AgentsListResponse(
55
- agents=[AgentModel(name=agent.name, description=agent.description) for agent in agents.values()]
56
- )
57
-
58
- @app.get("/agents/{name}")
59
- async def read_agent(name: AgentName) -> AgentReadResponse:
60
- agent = find_agent(name)
61
- return AgentModel(name=agent.name, description=agent.description)
62
-
63
- @app.post("/runs")
64
- async def create_run(request: RunCreateRequest) -> RunCreateResponse:
65
- agent = find_agent(request.agent_name)
66
- bundle = RunBundle(
67
- agent=agent,
68
- run=Run(
69
- agent_name=agent.name,
70
- session_id=request.session_id,
71
- ),
72
- )
73
-
74
- bundle.task = asyncio.create_task(bundle.execute(request.input))
75
- runs[bundle.run.run_id] = bundle
76
-
77
- match request.mode:
78
- case RunMode.STREAM:
79
- return StreamingResponse(
80
- stream_sse(bundle),
81
- media_type="text/event-stream",
82
- )
83
- case RunMode.SYNC:
84
- await bundle.join()
85
- return bundle.run
86
- case RunMode.ASYNC:
87
- return JSONResponse(
88
- status_code=status.HTTP_202_ACCEPTED,
89
- content=bundle.run.model_dump(),
90
- )
91
- case _:
92
- raise NotImplementedError()
93
-
94
- @app.get("/runs/{run_id}")
95
- async def read_run(run_id: RunId) -> RunReadResponse:
96
- bundle = find_run_bundle(run_id)
97
- return bundle.run
98
-
99
- @app.post("/runs/{run_id}")
100
- async def resume_run(run_id: RunId, request: RunResumeRequest) -> RunResumeResponse:
101
- bundle = find_run_bundle(run_id)
102
- bundle.stream_queue = asyncio.Queue() # TODO improve
103
- await bundle.await_queue.put(request.await_)
104
- match request.mode:
105
- case RunMode.STREAM:
106
- return StreamingResponse(
107
- stream_sse(bundle),
108
- media_type="text/event-stream",
109
- )
110
- case RunMode.SYNC:
111
- await bundle.join()
112
- return bundle.run
113
- case RunMode.ASYNC:
114
- return JSONResponse(
115
- status_code=status.HTTP_202_ACCEPTED,
116
- content=bundle.run.model_dump(),
117
- )
118
- case _:
119
- raise NotImplementedError()
120
-
121
- @app.post("/runs/{run_id}/cancel")
122
- async def cancel_run(run_id: RunId) -> RunCancelResponse:
123
- bundle = find_run_bundle(run_id)
124
- if bundle.run.status.is_terminal:
125
- raise HTTPException(
126
- status_code=403,
127
- detail=f"Run with terminal status {bundle.run.status} can't be cancelled",
128
- )
129
- bundle.task.cancel()
130
- bundle.run.status = RunStatus.CANCELLING
131
- return JSONResponse(status_code=status.HTTP_202_ACCEPTED, content=bundle.run.model_dump())
132
-
133
- return app
1
+ import inspect
2
+ from collections.abc import AsyncGenerator
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from typing import Any, Callable
5
+
6
+ from acp_sdk.models import Message
7
+ from acp_sdk.server.agent import Agent, SyncAgent
8
+ from acp_sdk.server.app import create_app
9
+ from acp_sdk.server.context import Context
10
+ from acp_sdk.server.logging import configure_logger as configure_logger_func
11
+ from acp_sdk.server.telemetry import configure_telemetry as configure_telemetry_func
12
+ from acp_sdk.server.types import RunYield, RunYieldResume
13
+
14
+
15
+ class Server:
16
+ def __init__(self) -> None:
17
+ self.agents: list[Agent] = []
18
+
19
+ def agent(self, name: str | None = None, description: str | None = None) -> Callable:
20
+ """Decorator to register an agent."""
21
+
22
+ def decorator(fn: Callable) -> Callable:
23
+ # check agent's function signature
24
+ signature = inspect.signature(fn)
25
+ parameters = list(signature.parameters.values())
26
+
27
+ # validate agent's function
28
+ if inspect.isasyncgenfunction(fn):
29
+ if len(parameters) != 2:
30
+ raise TypeError(
31
+ "The agent generator function must have one 'input' argument and one 'context' argument"
32
+ )
33
+ else:
34
+ if len(parameters) != 2:
35
+ raise TypeError("The agent function must have one 'input' argument and one 'context' argument")
36
+
37
+ agent: Agent
38
+ if inspect.isasyncgenfunction(fn):
39
+
40
+ class DecoratedAgent(Agent):
41
+ @property
42
+ def name(self) -> str:
43
+ return name or fn.__name__
44
+
45
+ @property
46
+ def description(self) -> str:
47
+ return description or fn.__doc__ or ""
48
+
49
+ async def run(
50
+ self, input: Message, context: Context, executor: ThreadPoolExecutor
51
+ ) -> AsyncGenerator[RunYield, RunYieldResume]:
52
+ gen: AsyncGenerator[RunYield, RunYieldResume] = fn(input, context)
53
+ value = None
54
+ while True:
55
+ try:
56
+ value = yield await gen.asend(value)
57
+ except StopAsyncIteration:
58
+ break
59
+
60
+ agent = DecoratedAgent()
61
+ elif inspect.iscoroutinefunction(fn):
62
+
63
+ class DecoratedAgent(Agent):
64
+ @property
65
+ def name(self) -> str:
66
+ return name or fn.__name__
67
+
68
+ @property
69
+ def description(self) -> str:
70
+ return description or fn.__doc__ or ""
71
+
72
+ async def run(
73
+ self, input: Message, context: Context, executor: ThreadPoolExecutor
74
+ ) -> AsyncGenerator[RunYield, RunYieldResume]:
75
+ yield await fn(input, context)
76
+
77
+ agent = DecoratedAgent()
78
+ else:
79
+
80
+ class DecoratedAgent(SyncAgent):
81
+ @property
82
+ def name(self) -> str:
83
+ return name or fn.__name__
84
+
85
+ @property
86
+ def description(self) -> str:
87
+ return description or fn.__doc__ or ""
88
+
89
+ def run_sync(self, input: Message, context: Context, executor: ThreadPoolExecutor) -> None:
90
+ return fn(input, context)
91
+
92
+ agent = DecoratedAgent()
93
+
94
+ self.register(agent)
95
+ return fn
96
+
97
+ return decorator
98
+
99
+ def register(self, *agents: Agent) -> None:
100
+ self.agents.extend(agents)
101
+
102
+ def run(self, configure_logger: bool = True, configure_telemetry: bool = False, **kwargs: dict[str, Any]) -> None:
103
+ import uvicorn
104
+
105
+ if configure_logger:
106
+ configure_logger_func()
107
+ if configure_telemetry:
108
+ configure_telemetry_func()
109
+
110
+ uvicorn.run(create_app(*self.agents), **kwargs)
@@ -1,9 +1,14 @@
1
1
  import logging
2
2
  from importlib.metadata import version
3
- from typing import Any
4
3
 
5
- from opentelemetry import trace
4
+ from opentelemetry import metrics, trace
5
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
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.sdk._logs import LoggerProvider, LoggingHandler
9
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
10
+ from opentelemetry.sdk.metrics import MeterProvider
11
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
7
12
  from opentelemetry.sdk.resources import (
8
13
  SERVICE_NAME,
9
14
  SERVICE_NAMESPACE,
@@ -11,36 +16,37 @@ from opentelemetry.sdk.resources import (
11
16
  Resource,
12
17
  )
13
18
  from opentelemetry.sdk.trace import TracerProvider
14
- from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExportResult
19
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
15
20
 
16
- logger = logging.getLogger("uvicorn.error")
17
-
18
-
19
- class SilentOTLPSpanExporter(OTLPSpanExporter):
20
- def export(self, spans: Any) -> SpanExportResult:
21
- try:
22
- return super().export(spans)
23
- except Exception as e:
24
- logger.warning(f"OpenTelemetry Exporter failed silently: {e}")
25
- return SpanExportResult.FAILURE
21
+ root_logger = logging.getLogger()
26
22
 
27
23
 
28
24
  def configure_telemetry() -> None:
29
- current_provider = trace.get_tracer_provider()
30
-
31
- # Detect default provider and override
32
- if isinstance(current_provider, trace.ProxyTracerProvider):
33
- provider = TracerProvider(
34
- resource=Resource(
35
- attributes={
36
- SERVICE_NAME: "acp-server",
37
- SERVICE_NAMESPACE: "acp",
38
- SERVICE_VERSION: version("acp-sdk"),
39
- }
40
- )
41
- )
42
-
43
- processor = BatchSpanProcessor(SilentOTLPSpanExporter())
44
- provider.add_span_processor(processor)
45
-
46
- trace.set_tracer_provider(provider)
25
+ """Utility that configures opentelemetry with OTLP exporter"""
26
+
27
+ resource = Resource(
28
+ attributes={
29
+ SERVICE_NAME: "acp-server",
30
+ SERVICE_NAMESPACE: "acp",
31
+ SERVICE_VERSION: version("acp-sdk"),
32
+ }
33
+ )
34
+
35
+ # Traces
36
+ provider = TracerProvider(resource=resource)
37
+ processor = BatchSpanProcessor(OTLPSpanExporter())
38
+ provider.add_span_processor(processor)
39
+ trace.set_tracer_provider(provider)
40
+
41
+ # Metrics
42
+ meter_provider = MeterProvider(
43
+ resource=resource,
44
+ metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())],
45
+ )
46
+ metrics.set_meter_provider(meter_provider)
47
+
48
+ # Logs
49
+ logger_provider = LoggerProvider(resource=resource)
50
+ processor = BatchLogRecordProcessor(OTLPLogExporter())
51
+ logger_provider.add_log_record_processor(processor)
52
+ root_logger.addHandler(LoggingHandler(logger_provider=logger_provider))
@@ -0,0 +1,6 @@
1
+ from typing import Any
2
+
3
+ from acp_sdk.models import Await, AwaitResume, Message
4
+
5
+ RunYield = Message | Await | dict[str | Any]
6
+ RunYieldResume = AwaitResume | None
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: acp-sdk
3
+ Version: 1.0.0rc4
4
+ Summary: Agent Communication Protocol SDK
5
+ Requires-Python: <4.0,>=3.11
6
+ Requires-Dist: opentelemetry-api>=1.31.1
7
+ Requires-Dist: pydantic>=2.11.1
8
+ Provides-Extra: client
9
+ Requires-Dist: httpx-sse>=0.4.0; extra == 'client'
10
+ Requires-Dist: httpx>=0.28.1; extra == 'client'
11
+ Requires-Dist: opentelemetry-instrumentation-httpx>=0.52b1; extra == 'client'
12
+ Provides-Extra: server
13
+ Requires-Dist: fastapi[standard]>=0.115.8; extra == 'server'
14
+ Requires-Dist: janus>=2.0.0; extra == 'server'
15
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.31.1; extra == 'server'
16
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.52b1; extra == 'server'
17
+ Requires-Dist: opentelemetry-sdk>=1.31.1; extra == 'server'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Agent Communication Protocol SDK for Python
21
+
22
+ Agent Communication Protocol SDK for Python provides allows developers to serve and consume agents over the Agent Communication Protocol.
23
+
24
+ ## Prerequisites
25
+
26
+ ✅ Python >= 3.11
27
+
28
+ ## Installation
29
+
30
+ Install to use client:
31
+
32
+ ```shell
33
+ pip install acp-sdk[client]
34
+ ```
35
+
36
+ Install to use server:
37
+
38
+ ```shell
39
+ pip install acp-sdk[server]
40
+ ```
41
+
42
+ ## Overview
43
+
44
+ ### Client
45
+
46
+ The `client` submodule exposes [httpx]() based client with simple methods for communication over ACP.
47
+
48
+ ```python
49
+ async with Client(base_url="http://localhost:8000") as client:
50
+ run = await client.run_sync(agent="echo", input=Message(TextMessagePart(content="Howdy!")))
51
+ print(run.output)
52
+ ```
53
+
54
+ ### Server
55
+
56
+ The `server` submodule exposes [fastapi] application factory that makes it easy to expose any agent over ACP.
57
+
58
+ ```python
59
+ class EchoAgent(Agent):
60
+ @property
61
+ def name(self) -> str:
62
+ return "echo"
63
+
64
+ @property
65
+ def description(self) -> str:
66
+ return "Echoes everything"
67
+
68
+ async def run(self, input: Message, *, context: Context) -> AsyncGenerator[Message | Await, AwaitResume]:
69
+ for part in input:
70
+ await asyncio.sleep(0.5)
71
+ yield {"thought": "I should echo everyting"}
72
+ yield Message(part)
73
+
74
+
75
+ serve(EchoAgent())
76
+ ```
77
+
78
+ ➡️ Explore more in our [examples library](/python/examples).
@@ -0,0 +1,22 @@
1
+ acp_sdk/__init__.py,sha256=XlcAbmvJfvJK5tmOFdxXP19aDwxxdG_jygsTCvXtjTk,43
2
+ acp_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ acp_sdk/client/__init__.py,sha256=Bca1DORrswxzZsrR2aUFpATuNG2xNSmYvF1Z2WJaVbc,51
4
+ acp_sdk/client/client.py,sha256=W6iXxH65UUODnvt3tnvgdqT_pP1meZUF2HwJpWCM0BM,5029
5
+ acp_sdk/models/__init__.py,sha256=numSDBDT1QHx7n_Y3Deb5VOvKWcUBxbOEaMwQBSRHxc,151
6
+ acp_sdk/models/errors.py,sha256=rEyaMVvQuBi7fwWe_d0PGGySYsD3FZTluQ-SkC0yhAs,444
7
+ acp_sdk/models/models.py,sha256=ACZPQp-wm43tdlx83hrX7QmdtBRWVXvU5bsotUOidHM,3908
8
+ acp_sdk/models/schemas.py,sha256=a0T29vfy93uVIce3ikbd3GSTdKBokMmfXWNVRJD3Eb8,662
9
+ acp_sdk/server/__init__.py,sha256=Qd0uTLaStTGxRaHvtXQpTWoruGvRGu8L17d8IMoPgu8,447
10
+ acp_sdk/server/agent.py,sha256=pAJkUR0OJn7qzsW7JPk3YIjRoPrSFljcWXcH4kBuRCM,2214
11
+ acp_sdk/server/app.py,sha256=7alacNo9y_f0-BjZI0dm_rzSweW-n5YiuY0hrVtEIR8,5420
12
+ acp_sdk/server/bundle.py,sha256=J-1prwhTHeK29ONjFERbv7gNY3YVSCeVI0p5687y-WQ,5076
13
+ acp_sdk/server/context.py,sha256=DIs4T61nIkangT2Mdk5oEHmK__XzfssqtodQ266CvoQ,758
14
+ acp_sdk/server/errors.py,sha256=fWlgVsQ5hs_AXwzc-wvy6QgoDWEMRUBlSrfJfhHHMyE,2085
15
+ acp_sdk/server/logging.py,sha256=Oc8yZigCsuDnHHPsarRzu0RX3NKaLEgpELM2yovGKDI,411
16
+ acp_sdk/server/server.py,sha256=hhju0fXo0vmkD5QYgT_zbZUDZXnCCFQzF3AZCMSgWko,4120
17
+ acp_sdk/server/telemetry.py,sha256=EwmtUrWMYid7XHiX76V1J6CigJPa2NrzEPOX0fBoY3o,1838
18
+ acp_sdk/server/types.py,sha256=2yJPkfUzjVIhHmc0SegGTMqDROe2uFgycb-7CATvYVw,161
19
+ acp_sdk/server/utils.py,sha256=EfrF9VCyVk3AM_ao-BIB9EzGbfTrh4V2Bz-VFr6f6Sg,351
20
+ acp_sdk-1.0.0rc4.dist-info/METADATA,sha256=qHSrUOETNJflMDnlTh8dWQh1sHNdO0OZOStlOJgzjdk,2147
21
+ acp_sdk-1.0.0rc4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
22
+ acp_sdk-1.0.0rc4.dist-info/RECORD,,
@@ -1,53 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: acp-sdk
3
- Version: 1.0.0rc2
4
- Summary: Agent Communication Protocol SDK
5
- Requires-Python: <4.0,>=3.11
6
- Requires-Dist: opentelemetry-api>=1.31.1
7
- Requires-Dist: pydantic>=2.11.1
8
- Provides-Extra: client
9
- Requires-Dist: httpx-sse>=0.4.0; extra == 'client'
10
- Requires-Dist: httpx>=0.28.1; extra == 'client'
11
- Requires-Dist: opentelemetry-instrumentation-httpx>=0.52b1; extra == 'client'
12
- Provides-Extra: server
13
- Requires-Dist: fastapi[standard]>=0.115.8; extra == 'server'
14
- Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.31.1; extra == 'server'
15
- Requires-Dist: opentelemetry-instrumentation-fastapi>=0.52b1; extra == 'server'
16
- Requires-Dist: opentelemetry-sdk>=1.31.1; extra == 'server'
17
- Description-Content-Type: text/markdown
18
-
19
- # Agent Communication Protocol SDK for Python
20
-
21
- ## Prerequisites
22
-
23
- ✅ Python >= 3.13
24
-
25
- ## Installation
26
-
27
- Install using pip:
28
-
29
- ```shell
30
- pip install acp-sdk
31
- ```
32
-
33
- ## Example
34
-
35
- The SDK can be used to implement both clients and servers.
36
-
37
- ### Server
38
-
39
- To run an example server use [FastAPI CLI](https://fastapi.tiangolo.com/fastapi-cli/) or other [deployment strategies](https://fastapi.tiangolo.com/deployment/):
40
-
41
- ```shell
42
- fastapi dev examples/servers/echo.py
43
- ```
44
-
45
- ### Client
46
-
47
- To run an example client:
48
-
49
- ```shell
50
- python examples/clients/simple.py
51
- ```
52
-
53
- ➡️ Explore more in our [examples library](/python/examples).
@@ -1,15 +0,0 @@
1
- acp_sdk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- acp_sdk/models.py,sha256=atSddtsQvNfiEIKHTckMwzahlTaOyyjuX6rrtD1Dft8,4345
3
- acp_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- acp_sdk/client/__init__.py,sha256=Bca1DORrswxzZsrR2aUFpATuNG2xNSmYvF1Z2WJaVbc,51
5
- acp_sdk/client/client.py,sha256=os14f1Q4TAMoXuhjpbrh67bBYpQS7KyT4H7a-WFN07Q,4489
6
- acp_sdk/server/__init__.py,sha256=iClvo3yLpMKLoUTTR9JeL_eR7R9YLFFcHTyFpg4xWfg,107
7
- acp_sdk/server/agent.py,sha256=yo31fL0H7boaHllICXAiXApHgBLD8fJfs1osJWUheuI,682
8
- acp_sdk/server/bundle.py,sha256=R3D5cPSoId6CZrs-ag3aUuxiK6sVdLw2EmOdMl3SoGg,4892
9
- acp_sdk/server/context.py,sha256=oILWXHNTXq3mLX0RM7jiIKMC19dEiw7AYy6X0L6OUXg,163
10
- acp_sdk/server/server.py,sha256=XvIznmda5UjjOUP37XRHDwKzVf-seKjVwkfshbKRuCo,4446
11
- acp_sdk/server/telemetry.py,sha256=rM22-0_SXtJn9T_xDmXalATAR1cwV8COJDY6EAg0jeE,1439
12
- acp_sdk/server/utils.py,sha256=EfrF9VCyVk3AM_ao-BIB9EzGbfTrh4V2Bz-VFr6f6Sg,351
13
- acp_sdk-1.0.0rc2.dist-info/METADATA,sha256=vGTZlnl6Ucgg4iPLxItlrAu446pJYitlKMLBuYNgEUI,1350
14
- acp_sdk-1.0.0rc2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
- acp_sdk-1.0.0rc2.dist-info/RECORD,,