acp-sdk 0.1.0rc7__tar.gz → 0.1.0rc8__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 (44) hide show
  1. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/PKG-INFO +5 -5
  2. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/README.md +4 -4
  3. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/examples/clients/advanced.py +2 -2
  4. acp_sdk-0.1.0rc8/examples/clients/session.py +18 -0
  5. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/examples/clients/simple.py +2 -2
  6. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/examples/clients/stream.py +1 -1
  7. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/examples/servers/awaiting.py +2 -2
  8. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/examples/servers/echo.py +4 -4
  9. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/pyproject.toml +1 -1
  10. acp_sdk-0.1.0rc8/pytest.ini +5 -0
  11. acp_sdk-0.1.0rc8/src/acp_sdk/__init__.py +2 -0
  12. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/client/client.py +49 -10
  13. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/models/models.py +11 -1
  14. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/models/schemas.py +1 -1
  15. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/server/agent.py +6 -11
  16. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/server/app.py +17 -15
  17. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/server/bundle.py +28 -13
  18. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/server/server.py +24 -2
  19. acp_sdk-0.1.0rc8/src/acp_sdk/server/session.py +21 -0
  20. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/server/telemetry.py +7 -2
  21. acp_sdk-0.1.0rc8/src/acp_sdk/version.py +3 -0
  22. acp_sdk-0.1.0rc8/tests/conftest.py +4 -0
  23. acp_sdk-0.1.0rc8/tests/e2e/config.py +2 -0
  24. acp_sdk-0.1.0rc8/tests/e2e/fixtures/__init__.py +0 -0
  25. acp_sdk-0.1.0rc8/tests/e2e/fixtures/client.py +12 -0
  26. acp_sdk-0.1.0rc8/tests/e2e/fixtures/server.py +38 -0
  27. acp_sdk-0.1.0rc8/tests/e2e/test_suites/__init__.py +0 -0
  28. acp_sdk-0.1.0rc8/tests/e2e/test_suites/test_runs.py +89 -0
  29. acp_sdk-0.1.0rc7/examples/servers/multi-echo.py +0 -57
  30. acp_sdk-0.1.0rc7/src/acp_sdk/__init__.py +0 -1
  31. acp_sdk-0.1.0rc7/tests/test_e2e.py +0 -113
  32. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/.gitignore +0 -0
  33. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/.python-version +0 -0
  34. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/client/__init__.py +0 -0
  35. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/models/__init__.py +0 -0
  36. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/models/errors.py +0 -0
  37. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/py.typed +0 -0
  38. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/server/__init__.py +0 -0
  39. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/server/context.py +0 -0
  40. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/server/errors.py +0 -0
  41. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/server/logging.py +0 -0
  42. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/server/types.py +0 -0
  43. {acp_sdk-0.1.0rc7 → acp_sdk-0.1.0rc8}/src/acp_sdk/server/utils.py +0 -0
  44. {acp_sdk-0.1.0rc7/tests → acp_sdk-0.1.0rc8/tests/e2e}/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acp-sdk
3
- Version: 0.1.0rc7
3
+ Version: 0.1.0rc8
4
4
  Summary: Agent Communication Protocol SDK
5
5
  Requires-Python: <4.0,>=3.11
6
6
  Requires-Dist: opentelemetry-api>=1.31.1
@@ -47,7 +47,7 @@ The `client` submodule exposes [httpx]() based client with simple methods for co
47
47
 
48
48
  ```python
49
49
  async with Client(base_url="http://localhost:8000") as client:
50
- run = await client.run_sync(agent="echo", input=Message(TextMessagePart(content="Howdy!")))
50
+ run = await client.run_sync(agent="echo", inputs=[Message(TextMessagePart(content="Howdy!"))])
51
51
  print(run.output)
52
52
  ```
53
53
 
@@ -59,13 +59,13 @@ The `server` submodule exposes [fastapi] application factory that makes it easy
59
59
  server = Server()
60
60
 
61
61
  @server.agent()
62
- async def echo(input: Message, context: Context) -> AsyncGenerator[RunYield, RunYieldResume]:
62
+ async def echo(inputs: list[Message], context: Context) -> AsyncGenerator[RunYield, RunYieldResume]:
63
63
  """Echoes everything"""
64
- for part in input:
64
+ for message in inputs:
65
65
  await asyncio.sleep(0.5)
66
66
  yield {"thought": "I should echo everyting"}
67
67
  await asyncio.sleep(0.5)
68
- yield Message(part)
68
+ yield message
69
69
 
70
70
 
71
71
  server.run()
@@ -28,7 +28,7 @@ The `client` submodule exposes [httpx]() based client with simple methods for co
28
28
 
29
29
  ```python
30
30
  async with Client(base_url="http://localhost:8000") as client:
31
- run = await client.run_sync(agent="echo", input=Message(TextMessagePart(content="Howdy!")))
31
+ run = await client.run_sync(agent="echo", inputs=[Message(TextMessagePart(content="Howdy!"))])
32
32
  print(run.output)
33
33
  ```
34
34
 
@@ -40,13 +40,13 @@ The `server` submodule exposes [fastapi] application factory that makes it easy
40
40
  server = Server()
41
41
 
42
42
  @server.agent()
43
- async def echo(input: Message, context: Context) -> AsyncGenerator[RunYield, RunYieldResume]:
43
+ async def echo(inputs: list[Message], context: Context) -> AsyncGenerator[RunYield, RunYieldResume]:
44
44
  """Echoes everything"""
45
- for part in input:
45
+ for message in inputs:
46
46
  await asyncio.sleep(0.5)
47
47
  yield {"thought": "I should echo everyting"}
48
48
  await asyncio.sleep(0.5)
49
- yield Message(part)
49
+ yield message
50
50
 
51
51
 
52
52
  server.run()
@@ -13,8 +13,8 @@ async def example() -> None:
13
13
  # Additional client configuration
14
14
  )
15
15
  ) as client:
16
- run = await client.run_sync(agent="echo", input=Message(TextMessagePart(content="Howdy!")))
17
- print(run.output)
16
+ run = await client.run_sync(agent="echo", inputs=[Message(TextMessagePart(content="Howdy!"))])
17
+ print(run.outputs)
18
18
 
19
19
 
20
20
  if __name__ == "__main__":
@@ -0,0 +1,18 @@
1
+ import asyncio
2
+
3
+ from acp_sdk.client import Client
4
+ from acp_sdk.models import (
5
+ Message,
6
+ TextMessagePart,
7
+ )
8
+
9
+
10
+ async def example() -> None:
11
+ async with Client(base_url="http://localhost:8000") as client, client.session() as session:
12
+ run = await session.run_sync(agent="historian", inputs=[Message(TextMessagePart(content="Howdy!"))])
13
+ run = await session.run_sync(agent="historian", inputs=[Message(TextMessagePart(content="Howdy again!"))])
14
+ print(run.outputs)
15
+
16
+
17
+ if __name__ == "__main__":
18
+ asyncio.run(example())
@@ -9,8 +9,8 @@ from acp_sdk.models import (
9
9
 
10
10
  async def example() -> None:
11
11
  async with Client(base_url="http://localhost:8000") as client:
12
- run = await client.run_sync(agent="echo", input=Message(TextMessagePart(content="Howdy!")))
13
- print(run.output)
12
+ run = await client.run_sync(agent="echo", inputs=[Message(TextMessagePart(content="Howdy!"))])
13
+ print(run.outputs)
14
14
 
15
15
 
16
16
  if __name__ == "__main__":
@@ -6,7 +6,7 @@ from acp_sdk.models import Message, TextMessagePart
6
6
 
7
7
  async def example() -> None:
8
8
  async with Client(base_url="http://localhost:8000") as client:
9
- async for event in client.run_stream(agent="echo", input=Message(TextMessagePart(content="Howdy!"))):
9
+ async for event in client.run_stream(agent="echo", inputs=[Message(TextMessagePart(content="Howdy!"))]):
10
10
  print(event)
11
11
 
12
12
 
@@ -13,11 +13,11 @@ server = Server()
13
13
 
14
14
 
15
15
  @server.agent()
16
- async def awaiting(input: Message, context: Context) -> AsyncGenerator[Message | Await | Any, AwaitResume]:
16
+ async def awaiting(inputs: list[Message], context: Context) -> AsyncGenerator[Message | Await | Any, AwaitResume]:
17
17
  """Greets and awaits for more data"""
18
18
  yield Message(TextMessagePart(content="Hello!"))
19
19
  data = yield Await()
20
20
  yield Message(TextMessagePart(content=f"Thanks for {data}"))
21
21
 
22
22
 
23
- server()
23
+ server.run()
@@ -10,13 +10,13 @@ server = Server()
10
10
 
11
11
 
12
12
  @server.agent()
13
- async def echo(input: Message, context: Context) -> AsyncGenerator[RunYield, RunYieldResume]:
13
+ async def echo(inputs: list[Message], context: Context) -> AsyncGenerator[RunYield, RunYieldResume]:
14
14
  """Echoes everything"""
15
- for part in input:
15
+ for message in inputs:
16
16
  await asyncio.sleep(0.5)
17
17
  yield {"thought": "I should echo everyting"}
18
18
  await asyncio.sleep(0.5)
19
- yield Message(part)
19
+ yield message
20
20
 
21
21
 
22
- server()
22
+ server.run()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acp-sdk"
3
- version = "0.1.0rc7"
3
+ version = "0.1.0rc8"
4
4
  description = "Agent Communication Protocol SDK"
5
5
  readme = "README.md"
6
6
  authors = []
@@ -0,0 +1,5 @@
1
+ [pytest]
2
+ testpaths = tests/e2e
3
+ python_files = test_*.py
4
+ python_functions = test_*
5
+ addopts = -v --strict-markers
@@ -0,0 +1,2 @@
1
+ from acp_sdk.models import * # noqa: F403
2
+ from acp_sdk.version import __version__ as __version__
@@ -1,4 +1,6 @@
1
- from collections.abc import AsyncIterator
1
+ import uuid
2
+ from collections.abc import AsyncGenerator, AsyncIterator
3
+ from contextlib import asynccontextmanager
2
4
  from types import TracebackType
3
5
  from typing import Self
4
6
 
@@ -14,6 +16,7 @@ from acp_sdk.models import (
14
16
  AgentReadResponse,
15
17
  AgentsListResponse,
16
18
  AwaitResume,
19
+ CreatedEvent,
17
20
  Error,
18
21
  Message,
19
22
  Run,
@@ -25,12 +28,20 @@ from acp_sdk.models import (
25
28
  RunMode,
26
29
  RunResumeRequest,
27
30
  RunResumeResponse,
31
+ SessionId,
28
32
  )
29
33
 
30
34
 
31
35
  class Client:
32
- def __init__(self, *, base_url: httpx.URL | str = "", client: httpx.AsyncClient | None = None) -> None:
36
+ def __init__(
37
+ self,
38
+ *,
39
+ base_url: httpx.URL | str = "",
40
+ session_id: SessionId | None = None,
41
+ client: httpx.AsyncClient | None = None,
42
+ ) -> None:
33
43
  self.base_url = base_url
44
+ self.session_id = session_id
34
45
 
35
46
  self._client = self._init_client(client)
36
47
 
@@ -51,6 +62,10 @@ class Client:
51
62
  ) -> None:
52
63
  await self._client.__aexit__(exc_type, exc_value, traceback)
53
64
 
65
+ @asynccontextmanager
66
+ async def session(self, session_id: SessionId | None = None) -> AsyncGenerator[Self]:
67
+ yield Client(client=self._client, session_id=session_id or uuid.uuid4())
68
+
54
69
  async def agents(self) -> AsyncIterator[Agent]:
55
70
  response = await self._client.get("/agents")
56
71
  self._raise_error(response)
@@ -62,30 +77,51 @@ class Client:
62
77
  self._raise_error(response)
63
78
  return AgentReadResponse.model_validate(response.json())
64
79
 
65
- async def run_sync(self, *, agent: AgentName, input: Message) -> Run:
80
+ async def run_sync(self, *, agent: AgentName, inputs: list[Message]) -> Run:
66
81
  response = await self._client.post(
67
82
  "/runs",
68
- json=RunCreateRequest(agent_name=agent, input=input, mode=RunMode.SYNC).model_dump(),
83
+ content=RunCreateRequest(
84
+ agent_name=agent,
85
+ inputs=inputs,
86
+ mode=RunMode.SYNC,
87
+ session_id=self.session_id,
88
+ ).model_dump_json(),
69
89
  )
70
90
  self._raise_error(response)
71
- return RunCreateResponse.model_validate(response.json())
91
+ response = RunCreateResponse.model_validate(response.json())
92
+ self._set_session(response)
93
+ return response
72
94
 
73
- async def run_async(self, *, agent: AgentName, input: Message) -> Run:
95
+ async def run_async(self, *, agent: AgentName, inputs: list[Message]) -> Run:
74
96
  response = await self._client.post(
75
97
  "/runs",
76
- json=RunCreateRequest(agent_name=agent, input=input, mode=RunMode.ASYNC).model_dump(),
98
+ content=RunCreateRequest(
99
+ agent_name=agent,
100
+ inputs=inputs,
101
+ mode=RunMode.ASYNC,
102
+ session_id=self.session_id,
103
+ ).model_dump_json(),
77
104
  )
78
105
  self._raise_error(response)
79
- return RunCreateResponse.model_validate(response.json())
106
+ response = RunCreateResponse.model_validate(response.json())
107
+ self._set_session(response)
108
+ return response
80
109
 
81
- async def run_stream(self, *, agent: AgentName, input: Message) -> AsyncIterator[RunEvent]:
110
+ async def run_stream(self, *, agent: AgentName, inputs: list[Message]) -> AsyncIterator[RunEvent]:
82
111
  async with aconnect_sse(
83
112
  self._client,
84
113
  "POST",
85
114
  "/runs",
86
- json=RunCreateRequest(agent_name=agent, input=input, mode=RunMode.STREAM).model_dump(),
115
+ content=RunCreateRequest(
116
+ agent_name=agent,
117
+ inputs=inputs,
118
+ mode=RunMode.STREAM,
119
+ session_id=self.session_id,
120
+ ).model_dump_json(),
87
121
  ) as event_source:
88
122
  async for event in self._validate_stream(event_source):
123
+ if isinstance(event, CreatedEvent):
124
+ self._set_session(event.run)
89
125
  yield event
90
126
 
91
127
  async def run_status(self, *, run_id: RunId) -> Run:
@@ -137,3 +173,6 @@ class Client:
137
173
  response.raise_for_status()
138
174
  except httpx.HTTPError:
139
175
  raise ACPError(Error.model_validate(response.json()))
176
+
177
+ def _set_session(self, run: Run) -> None:
178
+ self.session_id = run.session_id
@@ -91,13 +91,18 @@ class AwaitResume(BaseModel):
91
91
  pass
92
92
 
93
93
 
94
+ class Artifact(BaseModel):
95
+ pass
96
+
97
+
94
98
  class Run(BaseModel):
95
99
  run_id: RunId = Field(default_factory=uuid.uuid4)
96
100
  agent_name: AgentName
97
101
  session_id: SessionId | None = None
98
102
  status: RunStatus = RunStatus.CREATED
99
103
  await_: Await | None = Field(None, alias="await")
100
- output: Message | None = None
104
+ outputs: list[Message] = []
105
+ artifacts: list[Artifact] = []
101
106
  error: Error | None = None
102
107
 
103
108
  model_config = ConfigDict(populate_by_name=True)
@@ -117,6 +122,11 @@ class MessageEvent(BaseModel):
117
122
  message: Message
118
123
 
119
124
 
125
+ class ArtifactEvent(BaseModel):
126
+ type: Literal["artifact"] = "artifact"
127
+ artifact: Artifact
128
+
129
+
120
130
  class AwaitEvent(BaseModel):
121
131
  type: Literal["await"] = "await"
122
132
  await_: Await | None = Field(alias="await")
@@ -14,7 +14,7 @@ class AgentReadResponse(Agent):
14
14
  class RunCreateRequest(BaseModel):
15
15
  agent_name: AgentName
16
16
  session_id: SessionId | None = None
17
- input: Message
17
+ inputs: list[Message]
18
18
  mode: RunMode = RunMode.SYNC
19
19
 
20
20
 
@@ -31,19 +31,14 @@ class Agent(abc.ABC):
31
31
 
32
32
  @abc.abstractmethod
33
33
  def run(
34
- self, input: Message, context: Context
34
+ self, inputs: list[Message], context: Context
35
35
  ) -> (
36
36
  AsyncGenerator[RunYield, RunYieldResume] | Generator[RunYield, RunYieldResume] | Coroutine[RunYield] | RunYield
37
37
  ):
38
38
  pass
39
39
 
40
- async def session(self, session_id: SessionId | None) -> SessionId | None:
41
- if session_id:
42
- raise NotImplementedError()
43
- return None
44
-
45
40
  async def execute(
46
- self, input: Message, session_id: SessionId | None, executor: ThreadPoolExecutor
41
+ self, inputs: list[Message], session_id: SessionId | None, executor: ThreadPoolExecutor
47
42
  ) -> AsyncGenerator[RunYield, RunYieldResume]:
48
43
  yield_queue: janus.Queue[RunYield] = janus.Queue()
49
44
  yield_resume_queue: janus.Queue[RunYieldResume] = janus.Queue()
@@ -53,13 +48,13 @@ class Agent(abc.ABC):
53
48
  )
54
49
 
55
50
  if inspect.isasyncgenfunction(self.run):
56
- run = asyncio.create_task(self._run_async_gen(input, context))
51
+ run = asyncio.create_task(self._run_async_gen(inputs, context))
57
52
  elif inspect.iscoroutinefunction(self.run):
58
- run = asyncio.create_task(self._run_coro(input, context))
53
+ run = asyncio.create_task(self._run_coro(inputs, context))
59
54
  elif inspect.isgeneratorfunction(self.run):
60
- run = asyncio.get_running_loop().run_in_executor(executor, self._run_gen, input, context)
55
+ run = asyncio.get_running_loop().run_in_executor(executor, self._run_gen, inputs, context)
61
56
  else:
62
- run = asyncio.get_running_loop().run_in_executor(executor, self._run_func, input, context)
57
+ run = asyncio.get_running_loop().run_in_executor(executor, self._run_func, inputs, context)
63
58
 
64
59
  try:
65
60
  while True:
@@ -1,4 +1,3 @@
1
- import asyncio
2
1
  from collections.abc import AsyncGenerator
3
2
  from concurrent.futures import ThreadPoolExecutor
4
3
  from contextlib import asynccontextmanager
@@ -25,7 +24,7 @@ from acp_sdk.models import (
25
24
  RunReadResponse,
26
25
  RunResumeRequest,
27
26
  RunResumeResponse,
28
- RunStatus,
27
+ SessionId,
29
28
  )
30
29
  from acp_sdk.models.errors import ACPError
31
30
  from acp_sdk.server.agent import Agent
@@ -38,6 +37,7 @@ from acp_sdk.server.errors import (
38
37
  http_exception_handler,
39
38
  validation_exception_handler,
40
39
  )
40
+ from acp_sdk.server.session import Session
41
41
  from acp_sdk.server.utils import stream_sse
42
42
 
43
43
 
@@ -51,7 +51,7 @@ def create_app(*agents: Agent) -> FastAPI:
51
51
  @asynccontextmanager
52
52
  async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
53
53
  nonlocal executor
54
- with ThreadPoolExecutor(max_workers=5) as exec:
54
+ with ThreadPoolExecutor() as exec:
55
55
  executor = exec
56
56
  yield
57
57
 
@@ -61,6 +61,7 @@ def create_app(*agents: Agent) -> FastAPI:
61
61
 
62
62
  agents: dict[AgentName, Agent] = {agent.name: agent for agent in agents}
63
63
  runs: dict[RunId, RunBundle] = {}
64
+ sessions: dict[SessionId, Session] = {}
64
65
 
65
66
  app.exception_handler(ACPError)(acp_error_handler)
66
67
  app.exception_handler(StarletteHTTPException)(http_exception_handler)
@@ -96,17 +97,20 @@ def create_app(*agents: Agent) -> FastAPI:
96
97
  @app.post("/runs")
97
98
  async def create_run(request: RunCreateRequest) -> RunCreateResponse:
98
99
  agent = find_agent(request.agent_name)
100
+
101
+ session = sessions.get(request.session_id, Session()) if request.session_id else Session()
102
+ nonlocal executor
99
103
  bundle = RunBundle(
100
104
  agent=agent,
101
- run=Run(
102
- agent_name=agent.name,
103
- session_id=request.session_id,
104
- ),
105
+ run=Run(agent_name=agent.name, session_id=session.id),
106
+ inputs=request.inputs,
107
+ history=list(session.history()),
108
+ executor=executor,
105
109
  )
110
+ session.append(bundle)
106
111
 
107
- nonlocal executor
108
- bundle.task = asyncio.create_task(bundle.execute(request.input, executor=executor))
109
112
  runs[bundle.run.run_id] = bundle
113
+ sessions[session.id] = session
110
114
 
111
115
  headers = {Headers.RUN_ID: str(bundle.run.run_id)}
112
116
 
@@ -140,8 +144,7 @@ def create_app(*agents: Agent) -> FastAPI:
140
144
  @app.post("/runs/{run_id}")
141
145
  async def resume_run(run_id: RunId, request: RunResumeRequest) -> RunResumeResponse:
142
146
  bundle = find_run_bundle(run_id)
143
- bundle.stream_queue = asyncio.Queue() # TODO improve
144
- await bundle.await_queue.put(request.await_)
147
+ await bundle.resume(request.await_)
145
148
  match request.mode:
146
149
  case RunMode.STREAM:
147
150
  return StreamingResponse(
@@ -164,11 +167,10 @@ def create_app(*agents: Agent) -> FastAPI:
164
167
  bundle = find_run_bundle(run_id)
165
168
  if bundle.run.status.is_terminal:
166
169
  raise HTTPException(
167
- status_code=403,
168
- detail=f"Run with terminal status {bundle.run.status} can't be cancelled",
170
+ status_code=status.HTTP_403_FORBIDDEN,
171
+ detail=f"Run in terminal status {bundle.run.status} can't be cancelled",
169
172
  )
170
- bundle.task.cancel()
171
- bundle.run.status = RunStatus.CANCELLING
173
+ await bundle.cancel()
172
174
  return JSONResponse(status_code=status.HTTP_202_ACCEPTED, content=jsonable_encoder(bundle.run))
173
175
 
174
176
  return app
@@ -3,11 +3,12 @@ import logging
3
3
  from collections.abc import AsyncGenerator
4
4
  from concurrent.futures import ThreadPoolExecutor
5
5
 
6
- from opentelemetry import trace
7
6
  from pydantic import ValidationError
8
7
 
9
8
  from acp_sdk.models import (
10
9
  AnyModel,
10
+ Artifact,
11
+ ArtifactEvent,
11
12
  Await,
12
13
  AwaitEvent,
13
14
  AwaitResume,
@@ -27,20 +28,25 @@ from acp_sdk.models import (
27
28
  from acp_sdk.models.errors import ErrorCode
28
29
  from acp_sdk.server.agent import Agent
29
30
  from acp_sdk.server.logging import logger
31
+ from acp_sdk.server.telemetry import get_tracer
30
32
 
31
33
 
32
34
  class RunBundle:
33
- def __init__(self, *, agent: Agent, run: Run, task: asyncio.Task | None = None) -> None:
35
+ def __init__(
36
+ self, *, agent: Agent, run: Run, inputs: list[Message], history: list[Message], executor: ThreadPoolExecutor
37
+ ) -> None:
34
38
  self.agent = agent
35
39
  self.run = run
36
- self.task = task
40
+ self.inputs = inputs
41
+ self.history = history
37
42
 
38
43
  self.stream_queue: asyncio.Queue[RunEvent] = asyncio.Queue()
39
- self.composed_message = Message()
40
44
 
41
45
  self.await_queue: asyncio.Queue[AwaitResume] = asyncio.Queue(maxsize=1)
42
46
  self.await_or_terminate_event = asyncio.Event()
43
47
 
48
+ self.task = asyncio.create_task(self._execute(inputs, executor=executor))
49
+
44
50
  async def stream(self) -> AsyncGenerator[RunEvent]:
45
51
  while True:
46
52
  event = await self.stream_queue.get()
@@ -64,20 +70,27 @@ class RunBundle:
64
70
  async def resume(self, resume: AwaitResume) -> None:
65
71
  self.stream_queue = asyncio.Queue()
66
72
  await self.await_queue.put(resume)
73
+ self.run.status = RunStatus.IN_PROGRESS
74
+ self.run.await_ = None
75
+
76
+ async def cancel(self) -> None:
77
+ self.task.cancel()
78
+ self.run.status = RunStatus.CANCELLING
79
+ self.run.await_ = None
67
80
 
68
81
  async def join(self) -> None:
69
82
  await self.await_or_terminate_event.wait()
70
83
 
71
- async def execute(self, input: Message, *, executor: ThreadPoolExecutor) -> None:
72
- with trace.get_tracer(__name__).start_as_current_span("execute"):
84
+ async def _execute(self, inputs: list[Message], *, executor: ThreadPoolExecutor) -> None:
85
+ with get_tracer().start_as_current_span("run"):
73
86
  run_logger = logging.LoggerAdapter(logger, {"run_id": str(self.run.run_id)})
74
87
 
75
- await self.emit(CreatedEvent(run=self.run))
76
88
  try:
77
- self.run.session_id = await self.agent.session(self.run.session_id)
78
- run_logger.info("Session loaded")
89
+ await self.emit(CreatedEvent(run=self.run))
79
90
 
80
- generator = self.agent.execute(input=input, session_id=self.run.session_id, executor=executor)
91
+ generator = self.agent.execute(
92
+ inputs=self.history + inputs, session_id=self.run.session_id, executor=executor
93
+ )
81
94
  run_logger.info("Run started")
82
95
 
83
96
  self.run.status = RunStatus.IN_PROGRESS
@@ -87,8 +100,11 @@ class RunBundle:
87
100
  while True:
88
101
  next = await generator.asend(await_resume)
89
102
  if isinstance(next, Message):
90
- self.composed_message += next
103
+ self.run.outputs.append(next)
91
104
  await self.emit(MessageEvent(message=next))
105
+ elif isinstance(next, Artifact):
106
+ self.run.artifacts.append(next)
107
+ await self.emit(ArtifactEvent(artifact=next))
92
108
  elif isinstance(next, Await):
93
109
  self.run.await_ = next
94
110
  self.run.status = RunStatus.AWAITING
@@ -103,7 +119,6 @@ class RunBundle:
103
119
  )
104
120
  run_logger.info("Run awaited")
105
121
  await_resume = await self.await_()
106
- self.run.status = RunStatus.IN_PROGRESS
107
122
  await self.emit(InProgressEvent(run=self.run))
108
123
  run_logger.info("Run resumed")
109
124
  else:
@@ -113,7 +128,6 @@ class RunBundle:
113
128
  except ValidationError:
114
129
  raise TypeError("Invalid yield")
115
130
  except StopAsyncIteration:
116
- self.run.output = self.composed_message
117
131
  self.run.status = RunStatus.COMPLETED
118
132
  await self.emit(CompletedEvent(run=self.run))
119
133
  run_logger.info("Run completed")
@@ -126,6 +140,7 @@ class RunBundle:
126
140
  self.run.status = RunStatus.FAILED
127
141
  await self.emit(FailedEvent(run=self.run))
128
142
  run_logger.exception("Run failed")
143
+ raise
129
144
  finally:
130
145
  self.await_or_terminate_event.set()
131
146
  await self.stream_queue.put(None)
@@ -7,7 +7,7 @@ from typing import Any, Callable
7
7
  import uvicorn
8
8
  import uvicorn.config
9
9
 
10
- from acp_sdk.models import Message
10
+ from acp_sdk.models import Message, Metadata
11
11
  from acp_sdk.server.agent import Agent
12
12
  from acp_sdk.server.app import create_app
13
13
  from acp_sdk.server.context import Context
@@ -21,7 +21,13 @@ class Server:
21
21
  self._agents: list[Agent] = []
22
22
  self._server: uvicorn.Server | None = None
23
23
 
24
- def agent(self, name: str | None = None, description: str | None = None) -> Callable:
24
+ def agent(
25
+ self,
26
+ name: str | None = None,
27
+ description: str | None = None,
28
+ *,
29
+ metadata: Metadata | None = None,
30
+ ) -> Callable:
25
31
  """Decorator to register an agent."""
26
32
 
27
33
  def decorator(fn: Callable) -> Callable:
@@ -49,6 +55,10 @@ class Server:
49
55
  def description(self) -> str:
50
56
  return description or fn.__doc__ or ""
51
57
 
58
+ @property
59
+ def metadata(self) -> Metadata:
60
+ return metadata or Metadata()
61
+
52
62
  async def run(self, input: Message, context: Context) -> AsyncGenerator[RunYield, RunYieldResume]:
53
63
  try:
54
64
  gen: AsyncGenerator[RunYield, RunYieldResume] = (
@@ -72,6 +82,10 @@ class Server:
72
82
  def description(self) -> str:
73
83
  return description or fn.__doc__ or ""
74
84
 
85
+ @property
86
+ def metadata(self) -> Metadata:
87
+ return metadata or Metadata()
88
+
75
89
  async def run(self, input: Message, context: Context) -> Coroutine[RunYield]:
76
90
  return await (fn(input, context) if has_context_param else fn(input))
77
91
 
@@ -87,6 +101,10 @@ class Server:
87
101
  def description(self) -> str:
88
102
  return description or fn.__doc__ or ""
89
103
 
104
+ @property
105
+ def metadata(self) -> Metadata:
106
+ return metadata or Metadata()
107
+
90
108
  def run(self, input: Message, context: Context) -> Generator[RunYield, RunYieldResume]:
91
109
  yield from (fn(input, context) if has_context_param else fn(input))
92
110
 
@@ -102,6 +120,10 @@ class Server:
102
120
  def description(self) -> str:
103
121
  return description or fn.__doc__ or ""
104
122
 
123
+ @property
124
+ def metadata(self) -> Metadata:
125
+ return metadata or Metadata()
126
+
105
127
  def run(self, input: Message, context: Context) -> RunYield:
106
128
  return fn(input, context) if has_context_param else fn(input)
107
129
 
@@ -0,0 +1,21 @@
1
+ import uuid
2
+ from collections.abc import Iterator
3
+
4
+ from acp_sdk.models import Message, SessionId
5
+ from acp_sdk.models.models import RunStatus
6
+ from acp_sdk.server.bundle import RunBundle
7
+
8
+
9
+ class Session:
10
+ def __init__(self) -> None:
11
+ self.id: SessionId = uuid.uuid4()
12
+ self.bundles: list[RunBundle] = []
13
+
14
+ def append(self, bundle: RunBundle) -> None:
15
+ self.bundles.append(bundle)
16
+
17
+ def history(self) -> Iterator[Message]:
18
+ for bundle in self.bundles:
19
+ if bundle.run.status == RunStatus.COMPLETED:
20
+ yield from bundle.inputs
21
+ yield from bundle.run.outputs
@@ -1,5 +1,4 @@
1
1
  import logging
2
- from importlib.metadata import version
3
2
 
4
3
  from opentelemetry import metrics, trace
5
4
  from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
@@ -18,6 +17,8 @@ from opentelemetry.sdk.resources import (
18
17
  from opentelemetry.sdk.trace import TracerProvider
19
18
  from opentelemetry.sdk.trace.export import BatchSpanProcessor
20
19
 
20
+ from acp_sdk.version import __version__
21
+
21
22
  root_logger = logging.getLogger()
22
23
 
23
24
 
@@ -28,7 +29,7 @@ def configure_telemetry() -> None:
28
29
  attributes={
29
30
  SERVICE_NAME: "acp-server",
30
31
  SERVICE_NAMESPACE: "acp",
31
- SERVICE_VERSION: version("acp-sdk"),
32
+ SERVICE_VERSION: __version__,
32
33
  }
33
34
  )
34
35
 
@@ -50,3 +51,7 @@ def configure_telemetry() -> None:
50
51
  processor = BatchLogRecordProcessor(OTLPLogExporter())
51
52
  logger_provider.add_log_record_processor(processor)
52
53
  root_logger.addHandler(LoggingHandler(logger_provider=logger_provider))
54
+
55
+
56
+ def get_tracer() -> trace.Tracer:
57
+ return trace.get_tracer("acp-sdk", __version__)
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("acp-sdk")
@@ -0,0 +1,4 @@
1
+ pytest_plugins = [
2
+ "e2e.fixtures.client",
3
+ "e2e.fixtures.server",
4
+ ]
@@ -0,0 +1,2 @@
1
+ class Config:
2
+ PORT = 8000
File without changes
@@ -0,0 +1,12 @@
1
+ from collections.abc import AsyncIterator
2
+
3
+ import pytest_asyncio
4
+ from acp_sdk.client import Client
5
+
6
+ from e2e.config import Config
7
+
8
+
9
+ @pytest_asyncio.fixture
10
+ async def client() -> AsyncIterator[Client]:
11
+ async with Client(base_url=f"http://localhost:{Config.PORT}") as client:
12
+ yield client
@@ -0,0 +1,38 @@
1
+ import time
2
+ from collections.abc import AsyncGenerator, AsyncIterator, Generator
3
+ from threading import Thread
4
+
5
+ import pytest
6
+ from acp_sdk.models import Await, AwaitResume, Message, TextMessagePart
7
+ from acp_sdk.server import Context, Server
8
+
9
+ from e2e.config import Config
10
+
11
+
12
+ @pytest.fixture(scope="module")
13
+ def server() -> Generator[None]:
14
+ server = Server()
15
+
16
+ @server.agent()
17
+ async def echo(inputs: list[Message], context: Context) -> AsyncIterator[Message]:
18
+ for message in inputs:
19
+ yield message
20
+
21
+ @server.agent()
22
+ async def awaiter(inputs: list[Message], context: Context) -> AsyncGenerator[Message | Await, AwaitResume]:
23
+ yield Await()
24
+ yield Message(TextMessagePart(content="empty"))
25
+
26
+ @server.agent()
27
+ async def failer(inputs: list[Message], context: Context) -> AsyncIterator[Message]:
28
+ raise RuntimeError("Whoops")
29
+
30
+ thread = Thread(target=server.run, kwargs={"port": Config.PORT}, daemon=True)
31
+ thread.start()
32
+
33
+ time.sleep(1)
34
+
35
+ yield server
36
+
37
+ server.should_exit = True
38
+ thread.join(timeout=2)
File without changes
@@ -0,0 +1,89 @@
1
+ import pytest
2
+ from acp_sdk.client import Client
3
+ from acp_sdk.models import AwaitResume, CompletedEvent, CreatedEvent, Message, RunStatus, TextMessagePart
4
+ from acp_sdk.models.models import InProgressEvent
5
+ from acp_sdk.server import Server
6
+
7
+ inputs = [Message(TextMessagePart(content="Hello!"))]
8
+
9
+
10
+ @pytest.mark.asyncio
11
+ async def test_run_sync(server: Server, client: Client) -> None:
12
+ run = await client.run_sync(agent="echo", inputs=inputs)
13
+ assert run.status == RunStatus.COMPLETED
14
+ assert run.outputs == inputs
15
+
16
+
17
+ @pytest.mark.asyncio
18
+ async def test_run_async(server: Server, client: Client) -> None:
19
+ run = await client.run_async(agent="echo", inputs=inputs)
20
+ assert run.status == RunStatus.CREATED
21
+
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_run_stream(server: Server, client: Client) -> None:
25
+ event_stream = [event async for event in client.run_stream(agent="echo", inputs=inputs)]
26
+ assert isinstance(event_stream[0], CreatedEvent)
27
+ assert isinstance(event_stream[-1], CompletedEvent)
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_run_status(server: Server, client: Client) -> None:
32
+ run = await client.run_async(agent="echo", inputs=inputs)
33
+ while run.status in (RunStatus.CREATED, RunStatus.IN_PROGRESS):
34
+ run = await client.run_status(run_id=run.run_id)
35
+ assert run.status == RunStatus.COMPLETED
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_failure(server: Server, client: Client) -> None:
40
+ run = await client.run_sync(agent="failer", inputs=inputs)
41
+ assert run.status == RunStatus.FAILED
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_run_cancel(server: Server, client: Client) -> None:
46
+ run = await client.run_sync(agent="awaiter", inputs=inputs)
47
+ assert run.status == RunStatus.AWAITING
48
+ run = await client.run_cancel(run_id=run.run_id)
49
+ assert run.status == RunStatus.CANCELLING
50
+
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_run_resume_sync(server: Server, client: Client) -> None:
54
+ run = await client.run_sync(agent="awaiter", inputs=inputs)
55
+ assert run.status == RunStatus.AWAITING
56
+ assert run.await_ is not None
57
+
58
+ run = await client.run_resume_sync(run_id=run.run_id, await_=AwaitResume())
59
+ assert run.status == RunStatus.COMPLETED
60
+
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_run_resume_async(server: Server, client: Client) -> None:
64
+ run = await client.run_sync(agent="awaiter", inputs=inputs)
65
+ assert run.status == RunStatus.AWAITING
66
+ assert run.await_ is not None
67
+
68
+ run = await client.run_resume_async(run_id=run.run_id, await_=AwaitResume())
69
+ assert run.status == RunStatus.IN_PROGRESS
70
+
71
+
72
+ @pytest.mark.asyncio
73
+ async def test_run_resume_stream(server: Server, client: Client) -> None:
74
+ run = await client.run_sync(agent="awaiter", inputs=inputs)
75
+ assert run.status == RunStatus.AWAITING
76
+ assert run.await_ is not None
77
+
78
+ event_stream = [event async for event in client.run_resume_stream(run_id=run.run_id, await_=AwaitResume())]
79
+ assert isinstance(event_stream[0], InProgressEvent)
80
+ assert isinstance(event_stream[-1], CompletedEvent)
81
+
82
+
83
+ @pytest.mark.asyncio
84
+ async def test_run_session(server: Server, client: Client) -> None:
85
+ async with client.session() as session:
86
+ run = await session.run_sync(agent="echo", inputs=inputs)
87
+ assert run.outputs == inputs
88
+ run = await session.run_sync(agent="echo", inputs=inputs)
89
+ assert run.outputs == inputs + inputs + inputs
@@ -1,57 +0,0 @@
1
- from collections.abc import AsyncGenerator, Generator
2
-
3
- from acp_sdk.models import (
4
- Message,
5
- )
6
- from acp_sdk.server import Agent, Context, RunYield, RunYieldResume, Server
7
-
8
- server = Server()
9
-
10
-
11
- @server.agent()
12
- async def async_gen_echo(input: Message) -> AsyncGenerator[RunYield, RunYieldResume]:
13
- """Echoes everything"""
14
- yield {"thought": "I should echo everyting"}
15
- yield input
16
-
17
-
18
- @server.agent()
19
- async def async_echo(input: Message, context: Context) -> RunYield:
20
- """Echoes everything"""
21
- await context.yield_async({"thought": "I should echo everyting"})
22
- return input
23
-
24
-
25
- @server.agent()
26
- def gen_echo(input: Message) -> Generator[RunYield, RunYieldResume]:
27
- """Echoes everything"""
28
- yield {"thought": "I should echo everyting"}
29
- yield input
30
-
31
-
32
- @server.agent()
33
- def sync_echo(input: Message, context: Context) -> RunYield:
34
- """Echoes everything"""
35
- context.yield_sync({"thought": "I should echo everyting"})
36
- return input
37
-
38
-
39
- class EchoAgent(Agent):
40
- @property
41
- def name(self) -> str:
42
- return "instance_echo"
43
-
44
- @property
45
- def description(self) -> str:
46
- return "Echoes everything"
47
-
48
- async def run(self, input: Message, context: Context) -> AsyncGenerator[RunYield, RunYieldResume]:
49
- """Echoes everything"""
50
- yield {"thought": "I should echo everyting"}
51
- yield input
52
-
53
-
54
- server.register(EchoAgent())
55
-
56
-
57
- server()
@@ -1 +0,0 @@
1
- from acp_sdk.models import * # noqa: F403
@@ -1,113 +0,0 @@
1
- import time
2
- from collections.abc import AsyncGenerator, AsyncIterator, Generator
3
- from threading import Thread
4
-
5
- import pytest
6
- import pytest_asyncio
7
- from acp_sdk.client import Client
8
- from acp_sdk.models import Await, AwaitResume, CompletedEvent, CreatedEvent, Message, RunStatus, TextMessagePart
9
- from acp_sdk.models.models import InProgressEvent
10
- from acp_sdk.server import Context, Server
11
-
12
- PORT = 8000
13
-
14
-
15
- @pytest.fixture
16
- def server() -> Generator[None]:
17
- server = Server()
18
-
19
- @server.agent()
20
- async def echo(input: Message, context: Context) -> AsyncIterator[Message]:
21
- yield input
22
-
23
- @server.agent()
24
- async def awaiter(input: Message, context: Context) -> AsyncGenerator[Message | Await, AwaitResume]:
25
- yield Await()
26
- yield Message(TextMessagePart(content="empty"))
27
-
28
- thread = Thread(target=server.run, kwargs={"port": PORT}, daemon=True)
29
- thread.start()
30
-
31
- time.sleep(1)
32
-
33
- yield
34
-
35
- server.should_exit = True
36
- thread.join(timeout=2)
37
-
38
-
39
- @pytest_asyncio.fixture
40
- async def client() -> AsyncIterator[Client]:
41
- async with Client(base_url=f"http://localhost:{PORT}") as client:
42
- yield client
43
-
44
-
45
- @pytest.mark.asyncio
46
- async def test_run_sync(server: Server, client: Client) -> None:
47
- input = Message(TextMessagePart(content="Hello!"))
48
- run = await client.run_sync(agent="echo", input=input)
49
- assert run.status == RunStatus.COMPLETED
50
- assert run.output == input
51
-
52
-
53
- @pytest.mark.asyncio
54
- async def test_run_async(server: Server, client: Client) -> None:
55
- input = Message(TextMessagePart(content="Hello!"))
56
- run = await client.run_async(agent="echo", input=input)
57
- assert run.status == RunStatus.CREATED
58
- assert run.output is None
59
-
60
-
61
- @pytest.mark.asyncio
62
- async def test_run_stream(server: Server, client: Client) -> None:
63
- input = Message(TextMessagePart(content="Hello!"))
64
- event_stream = [event async for event in client.run_stream(agent="echo", input=input)]
65
- assert isinstance(event_stream[0], CreatedEvent)
66
- assert isinstance(event_stream[-1], CompletedEvent)
67
-
68
-
69
- @pytest.mark.asyncio
70
- async def test_run_status(server: Server, client: Client) -> None:
71
- input = Message(TextMessagePart(content="Hello!"))
72
- run = await client.run_async(agent="echo", input=input)
73
- while run.status in (RunStatus.CREATED, RunStatus.IN_PROGRESS):
74
- run = await client.run_status(run_id=run.run_id)
75
- assert run.status == RunStatus.COMPLETED
76
-
77
-
78
- @pytest.mark.asyncio
79
- async def test_run_resume_sync(server: Server, client: Client) -> None:
80
- input = Message(TextMessagePart(content="Hello!"))
81
-
82
- run = await client.run_sync(agent="awaiter", input=input)
83
- assert run.status == RunStatus.AWAITING
84
- assert run.await_ is not None
85
-
86
- run = await client.run_resume_sync(run_id=run.run_id, await_=AwaitResume())
87
- assert run.status == RunStatus.COMPLETED
88
- assert run.output is not None
89
-
90
-
91
- @pytest.mark.asyncio
92
- async def test_run_resume_async(server: Server, client: Client) -> None:
93
- input = Message(TextMessagePart(content="Hello!"))
94
-
95
- run = await client.run_sync(agent="awaiter", input=input)
96
- assert run.status == RunStatus.AWAITING
97
- assert run.await_ is not None
98
-
99
- run = await client.run_resume_async(run_id=run.run_id, await_=AwaitResume())
100
- assert run.status == RunStatus.AWAITING
101
-
102
-
103
- @pytest.mark.asyncio
104
- async def test_run_resume_stream(server: Server, client: Client) -> None:
105
- input = Message(TextMessagePart(content="Hello!"))
106
-
107
- run = await client.run_sync(agent="awaiter", input=input)
108
- assert run.status == RunStatus.AWAITING
109
- assert run.await_ is not None
110
-
111
- event_stream = [event async for event in client.run_resume_stream(run_id=run.run_id, await_=AwaitResume())]
112
- assert isinstance(event_stream[0], InProgressEvent)
113
- assert isinstance(event_stream[-1], CompletedEvent)
File without changes
File without changes