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.
- aaa_server-0.1.0/.gitignore +7 -0
- aaa_server-0.1.0/LICENSE +21 -0
- aaa_server-0.1.0/PKG-INFO +67 -0
- aaa_server-0.1.0/README.md +34 -0
- aaa_server-0.1.0/pyproject.toml +47 -0
- aaa_server-0.1.0/src/aaa_server/__init__.py +48 -0
- aaa_server-0.1.0/src/aaa_server/a2a.py +351 -0
- aaa_server-0.1.0/src/aaa_server/admission.py +247 -0
- aaa_server-0.1.0/src/aaa_server/app.py +723 -0
- aaa_server-0.1.0/src/aaa_server/auth.py +255 -0
- aaa_server-0.1.0/src/aaa_server/cli.py +100 -0
- aaa_server-0.1.0/src/aaa_server/mcp_http.py +64 -0
- aaa_server-0.1.0/src/aaa_server/py.typed +0 -0
aaa_server-0.1.0/LICENSE
ADDED
|
@@ -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}}
|