aaa-server 0.1.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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ site/
7
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Innerkore Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: aaa-server
3
+ Version: 0.1.0
4
+ Summary: HTTP/SSE server for AAA agents: authentication, tenant isolation, rate limits, quotas, approvals.
5
+ Project-URL: Homepage, https://github.com/innerkorehq/AutonomousAgentArchitecture
6
+ Project-URL: Documentation, https://innerkorehq.github.io/AutonomousAgentArchitecture/server/
7
+ Project-URL: Source, https://github.com/innerkorehq/AutonomousAgentArchitecture/tree/master/packages/aaa-server
8
+ Project-URL: Library, https://pypi.org/project/autonomous-agent-architecture/
9
+ Project-URL: Changelog, https://innerkorehq.github.io/AutonomousAgentArchitecture/about/changelog/
10
+ Project-URL: Threat model, https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/
11
+ Project-URL: Issues, https://github.com/innerkorehq/AutonomousAgentArchitecture/issues
12
+ Author: Innerkore Technologies
13
+ License-Expression: MIT
14
+ License-File: LICENSE
15
+ Keywords: agents,asgi,llm,multi-tenant,server,sse
16
+ Classifier: Development Status :: 3 - Alpha
17
+ Classifier: Framework :: AsyncIO
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: License :: OSI Approved :: MIT License
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: autonomous-agent-architecture>=0.3.1
26
+ Requires-Dist: starlette>=0.37
27
+ Requires-Dist: uvicorn>=0.30
28
+ Provides-Extra: jwt
29
+ Requires-Dist: pyjwt[crypto]>=2.8; extra == 'jwt'
30
+ Provides-Extra: redis
31
+ Requires-Dist: redis>=5.0; extra == 'redis'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # aaa-server
35
+
36
+ HTTP/SSE server for [AAA](https://github.com/innerkorehq/AutonomousAgentArchitecture) agents ([`autonomous-agent-architecture`](https://pypi.org/project/autonomous-agent-architecture/)): bearer authentication, tenants taken only from the credential, rate limits and quotas, server-side budgets, human approvals over HTTP, resumable event streams, plus MCP and (experimental) A2A endpoints.
37
+
38
+ **Documentation:** [overview](https://innerkorehq.github.io/AutonomousAgentArchitecture/server/) · [server guide](https://innerkorehq.github.io/AutonomousAgentArchitecture/guides/server/) · [threat model](https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/) · [API reference](https://innerkorehq.github.io/AutonomousAgentArchitecture/reference/api/server/) · [library docs](https://innerkorehq.github.io/AutonomousAgentArchitecture/) · [changelog](https://innerkorehq.github.io/AutonomousAgentArchitecture/about/changelog/)
39
+
40
+ ```bash
41
+ pip install aaa-server # needs autonomous-agent-architecture>=0.3.0 (installed automatically)
42
+ pip install "aaa-server[jwt,redis]" # + JWT/JWKS auth, + the shared Redis rate limiter
43
+ ```
44
+
45
+ ```python
46
+ # myapp/server.py
47
+ from aaa.algos import ToolLoop
48
+ from aaa.runtime import Budget, Engine
49
+ from aaa.tools import ToolRegistry
50
+ from aaa_server import ApiKeyAuthenticator, ServerConfig
51
+
52
+ async def build() -> ServerConfig:
53
+ engine = Engine() # + attach your LLM and tool handlers; use Postgres in production
54
+ return ServerConfig(
55
+ engine=engine,
56
+ authenticator=ApiKeyAuthenticator.from_keys({"s3cret-key": ("acme", "ann")}),
57
+ algorithms={"agent": ToolLoop(ToolRegistry([]))},
58
+ budget=Budget(max_steps=20, deadline_s=60),
59
+ )
60
+ ```
61
+
62
+ ```bash
63
+ aaa-server serve myapp.server:build # 127.0.0.1:8000 by default
64
+ ```
65
+
66
+ Read the [threat model](https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/) before exposing it: put a TLS-terminating reverse proxy in front.
67
+ Status: **alpha (0.1)**. MIT licensed. Source: <https://github.com/innerkorehq/AutonomousAgentArchitecture/tree/master/packages/aaa-server>.
@@ -0,0 +1,34 @@
1
+ # aaa-server
2
+
3
+ HTTP/SSE server for [AAA](https://github.com/innerkorehq/AutonomousAgentArchitecture) agents ([`autonomous-agent-architecture`](https://pypi.org/project/autonomous-agent-architecture/)): bearer authentication, tenants taken only from the credential, rate limits and quotas, server-side budgets, human approvals over HTTP, resumable event streams, plus MCP and (experimental) A2A endpoints.
4
+
5
+ **Documentation:** [overview](https://innerkorehq.github.io/AutonomousAgentArchitecture/server/) · [server guide](https://innerkorehq.github.io/AutonomousAgentArchitecture/guides/server/) · [threat model](https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/) · [API reference](https://innerkorehq.github.io/AutonomousAgentArchitecture/reference/api/server/) · [library docs](https://innerkorehq.github.io/AutonomousAgentArchitecture/) · [changelog](https://innerkorehq.github.io/AutonomousAgentArchitecture/about/changelog/)
6
+
7
+ ```bash
8
+ pip install aaa-server # needs autonomous-agent-architecture>=0.3.0 (installed automatically)
9
+ pip install "aaa-server[jwt,redis]" # + JWT/JWKS auth, + the shared Redis rate limiter
10
+ ```
11
+
12
+ ```python
13
+ # myapp/server.py
14
+ from aaa.algos import ToolLoop
15
+ from aaa.runtime import Budget, Engine
16
+ from aaa.tools import ToolRegistry
17
+ from aaa_server import ApiKeyAuthenticator, ServerConfig
18
+
19
+ async def build() -> ServerConfig:
20
+ engine = Engine() # + attach your LLM and tool handlers; use Postgres in production
21
+ return ServerConfig(
22
+ engine=engine,
23
+ authenticator=ApiKeyAuthenticator.from_keys({"s3cret-key": ("acme", "ann")}),
24
+ algorithms={"agent": ToolLoop(ToolRegistry([]))},
25
+ budget=Budget(max_steps=20, deadline_s=60),
26
+ )
27
+ ```
28
+
29
+ ```bash
30
+ aaa-server serve myapp.server:build # 127.0.0.1:8000 by default
31
+ ```
32
+
33
+ Read the [threat model](https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/) before exposing it: put a TLS-terminating reverse proxy in front.
34
+ Status: **alpha (0.1)**. MIT licensed. Source: <https://github.com/innerkorehq/AutonomousAgentArchitecture/tree/master/packages/aaa-server>.
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "aaa-server"
3
+ version = "0.1.0"
4
+ description = "HTTP/SSE server for AAA agents: authentication, tenant isolation, rate limits, quotas, approvals."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Innerkore Technologies" }]
9
+ keywords = ["agents", "llm", "asgi", "sse", "multi-tenant", "server"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Framework :: AsyncIO",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ ]
20
+ requires-python = ">=3.11"
21
+ dependencies = ["autonomous-agent-architecture>=0.3.1", "starlette>=0.37", "uvicorn>=0.30"]
22
+
23
+ [project.optional-dependencies]
24
+ jwt = ["pyjwt[crypto]>=2.8"]
25
+ redis = ["redis>=5.0"]
26
+
27
+ [project.scripts]
28
+ aaa-server = "aaa_server.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/innerkorehq/AutonomousAgentArchitecture"
32
+ Documentation = "https://innerkorehq.github.io/AutonomousAgentArchitecture/server/"
33
+ Source = "https://github.com/innerkorehq/AutonomousAgentArchitecture/tree/master/packages/aaa-server"
34
+ Library = "https://pypi.org/project/autonomous-agent-architecture/"
35
+ Changelog = "https://innerkorehq.github.io/AutonomousAgentArchitecture/about/changelog/"
36
+ "Threat model" = "https://innerkorehq.github.io/AutonomousAgentArchitecture/security/server/"
37
+ Issues = "https://github.com/innerkorehq/AutonomousAgentArchitecture/issues"
38
+
39
+ [build-system]
40
+ requires = ["hatchling>=1.27,<1.32"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/aaa_server"]
45
+
46
+ [tool.uv.sources]
47
+ autonomous-agent-architecture = { workspace = true }
@@ -0,0 +1,48 @@
1
+ """HTTP/SSE server for AAA agents: authentication, tenant isolation, rate limits, quotas and approvals."""
2
+
3
+ from .a2a import A2AConfig
4
+ from .admission import (
5
+ ConcurrencyLimiter,
6
+ Denied,
7
+ InMemoryRateLimiter,
8
+ Quota,
9
+ QuotaStore,
10
+ RateDecision,
11
+ RateLimiter,
12
+ RedisRateLimiter,
13
+ )
14
+ from .app import ServerConfig, clamp_budget, create_app, default_event_view
15
+ from .auth import (
16
+ ApiKey,
17
+ ApiKeyAuthenticator,
18
+ Authenticator,
19
+ AuthError,
20
+ JWKSKeys,
21
+ JWTAuthenticator,
22
+ Principal,
23
+ hash_api_key,
24
+ )
25
+
26
+ __all__ = [
27
+ "A2AConfig",
28
+ "ApiKey",
29
+ "ApiKeyAuthenticator",
30
+ "AuthError",
31
+ "Authenticator",
32
+ "ConcurrencyLimiter",
33
+ "Denied",
34
+ "InMemoryRateLimiter",
35
+ "JWKSKeys",
36
+ "JWTAuthenticator",
37
+ "Principal",
38
+ "Quota",
39
+ "QuotaStore",
40
+ "RateDecision",
41
+ "RateLimiter",
42
+ "RedisRateLimiter",
43
+ "ServerConfig",
44
+ "clamp_budget",
45
+ "create_app",
46
+ "default_event_view",
47
+ "hash_api_key",
48
+ ]
@@ -0,0 +1,351 @@
1
+ """Agent2Agent (A2A) protocol, EXPERIMENTAL: serve one algorithm as an A2A agent.
2
+
3
+ Targets **A2A 1.0**, the JSON-RPC binding (`SendMessage`, `SendStreamingMessage`, `GetTask`, `ListTasks`, `CancelTask`,
4
+ `SubscribeToTask`) and an agent card at `/.well-known/agent-card.json`. The wire shapes were taken from the protocol's own
5
+ generated types and checked against the official `a2a-sdk` client in the tests. Out of scope: push notifications, the extended
6
+ card, multi-turn tasks (a message naming an existing task is refused), non-text parts, gRPC and REST bindings.
7
+
8
+ A2A tasks ARE runs: they go through the same authentication, tenant scope, admission and budget as `POST /v1/runs`, and the
9
+ tenant comes only from the credential. A task that parks on a human approval is reported as `TASK_STATE_INPUT_REQUIRED`; the
10
+ approval itself is given through the REST API by a principal with the `approve` scope."""
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ from collections.abc import AsyncIterator, Callable
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, datetime
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from starlette.requests import Request
22
+ from starlette.responses import JSONResponse, Response
23
+
24
+ from aaa.core.ids import new_id
25
+ from aaa.core.outcome import OutcomeKind
26
+ from aaa.runtime.engine import RUN_COMPLETED, RUNMETA
27
+ from aaa.tools.handler import (
28
+ APPROVAL_DENIED,
29
+ APPROVAL_GRANTED,
30
+ APPROVAL_REQUESTED,
31
+ COMPLETED,
32
+ FAILED,
33
+ )
34
+
35
+ from .auth import Principal
36
+
37
+ if TYPE_CHECKING:
38
+ from .app import ServerApp
39
+
40
+ VERSION = "1.0"
41
+ TASK_NOT_FOUND, NOT_CANCELABLE, UNSUPPORTED_OP = -32001, -32002, -32004
42
+ CONTENT_TYPE, VERSION_UNSUPPORTED = -32005, -32009
43
+ INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL, PARSE_ERROR = (
44
+ -32600,
45
+ -32601,
46
+ -32602,
47
+ -32603,
48
+ -32700,
49
+ )
50
+ MAX_TEXT = 100_000
51
+
52
+
53
+ def _default_text(value: Any) -> str:
54
+ if isinstance(value, dict) and isinstance(value.get("text"), str):
55
+ return value["text"]
56
+ return value if isinstance(value, str) else json.dumps(value, default=str)
57
+
58
+
59
+ @dataclass
60
+ class A2AConfig:
61
+ """`url` is this server's public address (the card must carry an absolute URL)."""
62
+
63
+ algorithm: str # a key of ServerConfig.algorithms
64
+ url: str
65
+ name: str = "AAA agent"
66
+ description: str = "An agent served by aaa-server."
67
+ version: str = "0.1"
68
+ skills: tuple[dict[str, Any], ...] = ()
69
+ to_input: Callable[[str], dict[str, Any]] = lambda text: {"task": text}
70
+ result_text: Callable[[Any], str] = _default_text
71
+ wait_s: float = (
72
+ 300.0 # how long a blocking SendMessage waits before answering with the working task
73
+ )
74
+ extra: dict[str, Any] = field(default_factory=dict)
75
+
76
+ def card(self) -> dict[str, Any]:
77
+ skills = list(self.skills) or [
78
+ {"id": self.algorithm, "name": self.name, "description": self.description, "tags": []}
79
+ ]
80
+ return {
81
+ "name": self.name,
82
+ "description": self.description,
83
+ "version": self.version,
84
+ "supportedInterfaces": [
85
+ {
86
+ "url": self.url.rstrip("/") + "/a2a",
87
+ "protocolBinding": "JSONRPC",
88
+ "protocolVersion": VERSION,
89
+ }
90
+ ],
91
+ "capabilities": {"streaming": True, "pushNotifications": False},
92
+ "securitySchemes": {"bearer": {"httpAuthSecurityScheme": {"scheme": "Bearer"}}},
93
+ "securityRequirements": [{"schemes": {"bearer": {}}}],
94
+ "defaultInputModes": ["text/plain"],
95
+ "defaultOutputModes": ["text/plain"],
96
+ "skills": skills,
97
+ **self.extra,
98
+ }
99
+
100
+
101
+ class RpcError(Exception):
102
+ def __init__(self, code: int, message: str) -> None:
103
+ super().__init__(message)
104
+ self.code, self.message = code, message
105
+
106
+
107
+ def _now() -> str:
108
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
109
+
110
+
111
+ def _agent_message(task_id: str, text: str) -> dict[str, Any]:
112
+ return {
113
+ "messageId": new_id(),
114
+ "taskId": task_id,
115
+ "contextId": task_id,
116
+ "role": "ROLE_AGENT",
117
+ "parts": [{"text": text}],
118
+ }
119
+
120
+
121
+ class A2AEndpoint:
122
+ def __init__(self, app: ServerApp, cfg: A2AConfig) -> None:
123
+ self.app, self.cfg = app, cfg
124
+
125
+ @property
126
+ def algo(self) -> Any:
127
+ return self.app.cfg.algorithms[self.cfg.algorithm]
128
+
129
+ # -- task views -------------------------------------------------------------------------
130
+ async def _pending_approval(self, rid: str) -> bool:
131
+ asked, settled = set(), set()
132
+ for e in await self.app.engine.store.read(rid):
133
+ cid = e.data.get("call_id")
134
+ if e.type == APPROVAL_REQUESTED:
135
+ asked.add(cid)
136
+ elif e.type in (APPROVAL_GRANTED, APPROVAL_DENIED, COMPLETED, FAILED):
137
+ settled.add(cid)
138
+ return bool(asked - settled)
139
+
140
+ async def task(self, p: Principal, rid: str, *, artifacts: bool = True) -> dict[str, Any]:
141
+ tid = self.app._public(p, rid)
142
+ meta = await self.app.engine.project(RUNMETA, rid)
143
+ done, message = meta.completed, None
144
+ if done is None:
145
+ if await self._pending_approval(rid):
146
+ state, message = "TASK_STATE_INPUT_REQUIRED", "waiting for a human approval"
147
+ else:
148
+ state = "TASK_STATE_WORKING"
149
+ else:
150
+ kind = str(done.get("kind"))
151
+ state = {
152
+ OutcomeKind.SUCCESS.value: "TASK_STATE_COMPLETED",
153
+ OutcomeKind.CANCELLED.value: "TASK_STATE_CANCELED",
154
+ OutcomeKind.BLOCKED.value: "TASK_STATE_REJECTED",
155
+ }.get(kind, "TASK_STATE_FAILED")
156
+ if state != "TASK_STATE_COMPLETED":
157
+ message = done.get("reason") or str(kind)
158
+ out: dict[str, Any] = {
159
+ "id": tid,
160
+ "contextId": tid,
161
+ "status": {
162
+ "state": state,
163
+ "timestamp": _now(),
164
+ **({"message": _agent_message(tid, message)} if message else {}),
165
+ },
166
+ }
167
+ if artifacts and state == "TASK_STATE_COMPLETED":
168
+ out["artifacts"] = [
169
+ {
170
+ "artifactId": "result",
171
+ "name": "result",
172
+ "parts": [{"text": self.cfg.result_text(done.get("value") if done else None)}],
173
+ }
174
+ ]
175
+ return out
176
+
177
+ async def _owned(self, p: Principal, params: dict[str, Any]) -> Any:
178
+ tid = params.get("id")
179
+ if not isinstance(tid, str) or not tid:
180
+ raise RpcError(INVALID_PARAMS, "id is required")
181
+ try:
182
+ h = await self.app.engine.tenant(p.tenant).handle(tid)
183
+ started = await self.app.engine._started_of(h.run_id)
184
+ except KeyError:
185
+ raise RpcError(TASK_NOT_FOUND, "task not found") from None
186
+ if started.data.get("algo") != self.algo.name:
187
+ raise RpcError(TASK_NOT_FOUND, "task not found")
188
+ return h
189
+
190
+ # -- methods ----------------------------------------------------------------------------
191
+ def _text_of(self, params: dict[str, Any]) -> str:
192
+ msg = params.get("message")
193
+ if not isinstance(msg, dict) or msg.get("role") not in (None, "ROLE_USER"):
194
+ raise RpcError(INVALID_PARAMS, "message with role ROLE_USER is required")
195
+ if msg.get("taskId"):
196
+ raise RpcError(UNSUPPORTED_OP, "continuing an existing task is not supported")
197
+ parts = msg.get("parts")
198
+ if not isinstance(parts, list) or not parts:
199
+ raise RpcError(INVALID_PARAMS, "message.parts is required")
200
+ texts = []
201
+ for part in parts:
202
+ if not isinstance(part, dict) or not isinstance(part.get("text"), str):
203
+ raise RpcError(CONTENT_TYPE, "only text parts are supported")
204
+ texts.append(part["text"])
205
+ text = "\n".join(texts)
206
+ if len(text) > MAX_TEXT:
207
+ raise RpcError(INVALID_PARAMS, "message is too long")
208
+ return text
209
+
210
+ async def send(self, p: Principal, params: dict[str, Any]) -> dict[str, Any]:
211
+ text = self._text_of(params)
212
+ rid, _ = await self.app.launch(p, self.algo, self.cfg.to_input(text))
213
+ cfgd = params.get("configuration") or {}
214
+ if not (isinstance(cfgd, dict) and cfgd.get("returnImmediately")):
215
+ try:
216
+ await asyncio.wait_for(self.app.engine.handle(rid).result(), self.cfg.wait_s)
217
+ except TimeoutError:
218
+ pass
219
+ return {"task": await self.task(p, rid)}
220
+
221
+ async def get(self, p: Principal, params: dict[str, Any]) -> dict[str, Any]:
222
+ h = await self._owned(p, params)
223
+ return await self.task(p, h.run_id)
224
+
225
+ async def cancel(self, p: Principal, params: dict[str, Any]) -> dict[str, Any]:
226
+ h = await self._owned(p, params)
227
+ if (await self.app.engine.project(RUNMETA, h.run_id)).completed is not None:
228
+ raise RpcError(NOT_CANCELABLE, "task is already finished")
229
+ await h.cancel(f"cancelled by {p.subject}")
230
+ try:
231
+ await asyncio.wait_for(h.result(), 5)
232
+ except TimeoutError:
233
+ pass
234
+ return await self.task(p, h.run_id)
235
+
236
+ async def list(self, p: Principal, params: dict[str, Any]) -> dict[str, Any]:
237
+ infos = [
238
+ r
239
+ for r in await self.app.engine.tenant(p.tenant).runs()
240
+ if r.started.data.get("algo") == self.algo.name
241
+ ][-50:]
242
+ tasks = [await self.task(p, r.stream, artifacts=False) for r in infos]
243
+ return {
244
+ "tasks": tasks,
245
+ "nextPageToken": "",
246
+ "pageSize": len(tasks),
247
+ "totalSize": len(tasks),
248
+ }
249
+
250
+ async def frames(
251
+ self, p: Principal, rid: str, *, snapshot: dict[str, Any]
252
+ ) -> AsyncIterator[dict[str, Any]]:
253
+ """StreamResponse objects: the task, then text as it is written, then the terminal status."""
254
+ tid = snapshot["id"]
255
+ yield {"task": snapshot}
256
+ streamed = False
257
+ start = (
258
+ (await self.app.engine.store.last_seq(rid)) + 1
259
+ if snapshot["status"]["state"] != "TASK_STATE_WORKING"
260
+ else 0
261
+ )
262
+ async for e in self.app.engine.store.subscribe(rid, start):
263
+ if e.type == "aaa.llm.delta" and e.data.get("tag") in (None, "main"):
264
+ yield {
265
+ "artifactUpdate": {
266
+ "taskId": tid,
267
+ "contextId": tid,
268
+ "artifact": {
269
+ "artifactId": "stream",
270
+ "parts": [{"text": e.data.get("text", "")}],
271
+ },
272
+ "append": streamed,
273
+ }
274
+ }
275
+ streamed = True
276
+ elif e.type == RUN_COMPLETED:
277
+ final = await self.task(p, rid)
278
+ if final.get("artifacts"):
279
+ yield {
280
+ "artifactUpdate": {
281
+ "taskId": tid,
282
+ "contextId": tid,
283
+ "artifact": final["artifacts"][0],
284
+ "lastChunk": True,
285
+ }
286
+ }
287
+ yield {"statusUpdate": {"taskId": tid, "contextId": tid, "status": final["status"]}}
288
+ return
289
+
290
+ # -- HTTP entry --------------------------------------------------------------------------
291
+ async def rpc(self, request: Request, p: Principal) -> Response:
292
+ ver = request.headers.get("a2a-version")
293
+ raw = await self.app._raw(request)
294
+ try:
295
+ msg = json.loads(raw)
296
+ except ValueError:
297
+ return JSONResponse(self._err(None, PARSE_ERROR, "parse error"), 400)
298
+ if (
299
+ not isinstance(msg, dict)
300
+ or msg.get("jsonrpc") != "2.0"
301
+ or not isinstance(msg.get("method"), str)
302
+ ):
303
+ return JSONResponse(self._err(None, INVALID_REQUEST, "invalid JSON-RPC request"), 400)
304
+ id_, method, params = msg.get("id"), msg["method"], msg.get("params") or {}
305
+ if ver and ver.split(".")[0] != "1":
306
+ return JSONResponse(self._err(id_, VERSION_UNSUPPORTED, "only A2A 1.x is supported"))
307
+ if not isinstance(params, dict):
308
+ return JSONResponse(self._err(id_, INVALID_PARAMS, "params must be an object"))
309
+ try:
310
+ if method in ("SendStreamingMessage", "SubscribeToTask"):
311
+ return await self._stream(p, id_, method, params)
312
+ fn = {
313
+ "SendMessage": self.send,
314
+ "GetTask": self.get,
315
+ "CancelTask": self.cancel,
316
+ "ListTasks": self.list,
317
+ }.get(method)
318
+ if fn is None:
319
+ return JSONResponse(
320
+ self._err(id_, METHOD_NOT_FOUND, f"method {method!r} is not supported")
321
+ )
322
+ return JSONResponse({"jsonrpc": "2.0", "id": id_, "result": await fn(p, params)})
323
+ except RpcError as e:
324
+ return JSONResponse(self._err(id_, e.code, e.message))
325
+
326
+ async def _stream(
327
+ self, p: Principal, id_: Any, method: str, params: dict[str, Any]
328
+ ) -> Response:
329
+ from contextlib import AsyncExitStack
330
+
331
+ if method == "SendStreamingMessage":
332
+ text = self._text_of(params)
333
+ rid, _ = await self.app.launch(p, self.algo, self.cfg.to_input(text))
334
+ else:
335
+ h = await self._owned(p, params)
336
+ rid = h.run_id
337
+ if (await self.app.engine.project(RUNMETA, rid)).completed is not None:
338
+ raise RpcError(UNSUPPORTED_OP, "task is already finished")
339
+ snapshot = await self.task(p, rid)
340
+ stack = AsyncExitStack()
341
+ await stack.enter_async_context(self.app.streams.hold(p.tenant))
342
+
343
+ async def body() -> AsyncIterator[bytes]:
344
+ async for frame in self.frames(p, rid, snapshot=snapshot):
345
+ yield f"data: {json.dumps({'jsonrpc': '2.0', 'id': id_, 'result': frame})}\n\n".encode()
346
+
347
+ return self.app._stream_response(p, body, stack, trailer=False)
348
+
349
+ @staticmethod
350
+ def _err(id_: Any, code: int, message: str) -> dict[str, Any]:
351
+ return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}}