vystak-transport-http 0.2.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.
- vystak_transport_http-0.2.0/.gitignore +45 -0
- vystak_transport_http-0.2.0/PKG-INFO +18 -0
- vystak_transport_http-0.2.0/README.md +6 -0
- vystak_transport_http-0.2.0/pyproject.toml +37 -0
- vystak_transport_http-0.2.0/src/vystak_transport_http/__init__.py +6 -0
- vystak_transport_http-0.2.0/src/vystak_transport_http/delivery.py +24 -0
- vystak_transport_http-0.2.0/src/vystak_transport_http/plugin.py +39 -0
- vystak_transport_http-0.2.0/src/vystak_transport_http/transport.py +219 -0
- vystak_transport_http-0.2.0/tests/__init__.py +0 -0
- vystak_transport_http-0.2.0/tests/test_delivery.py +30 -0
- vystak_transport_http-0.2.0/tests/test_http_plugin.py +60 -0
- vystak_transport_http-0.2.0/tests/test_http_transport.py +219 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.eggs/
|
|
9
|
+
*.egg
|
|
10
|
+
.venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.pyright/
|
|
14
|
+
|
|
15
|
+
# TypeScript
|
|
16
|
+
node_modules/
|
|
17
|
+
*.tsbuildinfo
|
|
18
|
+
coverage/
|
|
19
|
+
|
|
20
|
+
# IDE
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/
|
|
23
|
+
*.swp
|
|
24
|
+
*.swo
|
|
25
|
+
*~
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
|
|
31
|
+
# Environment
|
|
32
|
+
.env
|
|
33
|
+
.env.local
|
|
34
|
+
|
|
35
|
+
# Vystak generated output (was .agentstack/ before rename)
|
|
36
|
+
.vystak/
|
|
37
|
+
.agentstack/
|
|
38
|
+
|
|
39
|
+
# Bundled templates copied into vystak-cli wheel at build time (build hook output)
|
|
40
|
+
packages/python/vystak-cli/src/vystak_cli/templates/
|
|
41
|
+
|
|
42
|
+
# Tooling workspaces (brainstorm sessions, local agent state)
|
|
43
|
+
.superpowers/
|
|
44
|
+
.worktrees/
|
|
45
|
+
.claude/
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vystak-transport-http
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: HTTP transport for Vystak east-west A2A traffic
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: fastapi>=0.115
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Requires-Dist: sse-starlette>=2.0
|
|
10
|
+
Requires-Dist: vystak
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# vystak-transport-http
|
|
14
|
+
|
|
15
|
+
HTTP implementation of the Vystak `Transport` ABC.
|
|
16
|
+
|
|
17
|
+
- `HttpTransport` — concrete Transport using httpx (client) and FastAPI (server's /a2a is already handled; serve() is a no-op).
|
|
18
|
+
- `HttpTransportPlugin` — `TransportPlugin` providing env-var contract and (empty) provisioning.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# vystak-transport-http
|
|
2
|
+
|
|
3
|
+
HTTP implementation of the Vystak `Transport` ABC.
|
|
4
|
+
|
|
5
|
+
- `HttpTransport` — concrete Transport using httpx (client) and FastAPI (server's /a2a is already handled; serve() is a no-op).
|
|
6
|
+
- `HttpTransportPlugin` — `TransportPlugin` providing env-var contract and (empty) provisioning.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vystak-transport-http"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "HTTP transport for Vystak east-west A2A traffic"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"vystak",
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
"fastapi>=0.115",
|
|
11
|
+
"sse-starlette>=2.0",
|
|
12
|
+
"pydantic>=2.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.0",
|
|
18
|
+
"pytest-asyncio>=0.23",
|
|
19
|
+
"uvicorn>=0.34",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/vystak_transport_http"]
|
|
28
|
+
|
|
29
|
+
[tool.uv.sources]
|
|
30
|
+
vystak = { workspace = true }
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
asyncio_mode = "auto"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.version]
|
|
36
|
+
source = "vcs"
|
|
37
|
+
raw-options = {root = "../../.."}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""HttpChannelDelivery — POST /deliver to the channel's HTTP delivery port."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from vystak_channel_runtime.delivery import ChannelDelivery, DeliveryRequest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HttpChannelDelivery(ChannelDelivery):
|
|
10
|
+
def __init__(self, channel_routes: dict[str, str]) -> None:
|
|
11
|
+
# canonical_name → base URL like http://host:9999
|
|
12
|
+
self._routes = dict(channel_routes)
|
|
13
|
+
|
|
14
|
+
async def deliver(
|
|
15
|
+
self,
|
|
16
|
+
channel_canonical_name: str,
|
|
17
|
+
request: DeliveryRequest,
|
|
18
|
+
*,
|
|
19
|
+
timeout: float = 30,
|
|
20
|
+
) -> None:
|
|
21
|
+
url = self._routes[channel_canonical_name].rstrip("/") + "/deliver"
|
|
22
|
+
async with httpx.AsyncClient(timeout=timeout) as c:
|
|
23
|
+
r = await c.post(url, json=request.model_dump(mode="json"))
|
|
24
|
+
r.raise_for_status()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""HttpTransportPlugin — registers the HTTP transport with providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from vystak.providers.base import GeneratedCode, TransportPlugin
|
|
6
|
+
from vystak.schema import Platform, Transport
|
|
7
|
+
from vystak.schema.agent import Agent
|
|
8
|
+
from vystak.transport.naming import slug
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HttpTransportPlugin(TransportPlugin):
|
|
12
|
+
"""HTTP transport plugin. No broker to provision; listener handled by
|
|
13
|
+
the generated FastAPI app already."""
|
|
14
|
+
|
|
15
|
+
type = "http"
|
|
16
|
+
|
|
17
|
+
def build_provision_nodes(self, transport: Transport, platform: Platform):
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
def generate_env_contract(self, transport: Transport, context: dict) -> dict[str, str]:
|
|
21
|
+
return {"VYSTAK_TRANSPORT_TYPE": "http"}
|
|
22
|
+
|
|
23
|
+
def generate_listener_code(self, transport: Transport) -> GeneratedCode | None:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
def resolve_address_for(self, agent: Agent, platform: Platform) -> str:
|
|
27
|
+
"""Return the Docker-style DNS URL for an agent.
|
|
28
|
+
|
|
29
|
+
Matches the Docker provider's container naming (`vystak-{agent_name}`)
|
|
30
|
+
since that's what DockerAgentNode actually creates and what Docker's
|
|
31
|
+
network DNS resolves on the shared vystak-net. Namespace is not
|
|
32
|
+
encoded in the URL because the container name is namespace-flat today
|
|
33
|
+
— v1 deployments are single-namespace per Docker host.
|
|
34
|
+
|
|
35
|
+
Azure providers override this (or use their own plugin) since the
|
|
36
|
+
ingress hostname isn't derivable from the name alone.
|
|
37
|
+
"""
|
|
38
|
+
port = agent.port or 8000
|
|
39
|
+
return f"http://vystak-{slug(agent.name)}:{port}/a2a"
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""HttpTransport — uses httpx (client) and relies on generated FastAPI /a2a (server)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
from vystak.transport import (
|
|
14
|
+
A2AEvent,
|
|
15
|
+
A2AMessage,
|
|
16
|
+
A2AResult,
|
|
17
|
+
AgentRef,
|
|
18
|
+
Transport,
|
|
19
|
+
)
|
|
20
|
+
from vystak.transport.base import ServerDispatcherProtocol
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HttpTransport(Transport):
|
|
26
|
+
"""HTTP implementation of the Transport ABC.
|
|
27
|
+
|
|
28
|
+
Client side: httpx POST to `{agent_url}/a2a` with JSON-RPC A2A envelope.
|
|
29
|
+
Server side: the generated agent already exposes `/a2a` via FastAPI;
|
|
30
|
+
`serve()` is a no-op.
|
|
31
|
+
|
|
32
|
+
Routes are supplied at construction time (typically built from
|
|
33
|
+
`VYSTAK_ROUTES_JSON` + the platform's canonical-to-URL mapping).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
type = "http"
|
|
37
|
+
supports_streaming = True
|
|
38
|
+
|
|
39
|
+
def __init__(self, routes: dict[str, str]) -> None:
|
|
40
|
+
"""`routes` maps canonical_name -> absolute URL ending in `/a2a`."""
|
|
41
|
+
self._routes = dict(routes)
|
|
42
|
+
|
|
43
|
+
def resolve_address(self, canonical_name: str) -> str:
|
|
44
|
+
try:
|
|
45
|
+
return self._routes[canonical_name]
|
|
46
|
+
except KeyError:
|
|
47
|
+
raise KeyError(
|
|
48
|
+
f"HttpTransport has no route for canonical name "
|
|
49
|
+
f"{canonical_name!r}; known: {sorted(self._routes)}"
|
|
50
|
+
) from None
|
|
51
|
+
|
|
52
|
+
async def send_task(
|
|
53
|
+
self,
|
|
54
|
+
agent: AgentRef,
|
|
55
|
+
message: A2AMessage,
|
|
56
|
+
metadata: dict[str, Any],
|
|
57
|
+
*,
|
|
58
|
+
timeout: float,
|
|
59
|
+
) -> A2AResult:
|
|
60
|
+
url = self.resolve_address(agent.canonical_name)
|
|
61
|
+
payload = self._build_payload("message/send", message, metadata)
|
|
62
|
+
try:
|
|
63
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
64
|
+
response = await client.post(url, json=payload)
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
body = response.json()
|
|
67
|
+
except httpx.TimeoutException as exc:
|
|
68
|
+
raise TimeoutError(str(exc)) from exc
|
|
69
|
+
return self._parse_result(body, message.correlation_id)
|
|
70
|
+
|
|
71
|
+
async def stream_task(
|
|
72
|
+
self,
|
|
73
|
+
agent: AgentRef,
|
|
74
|
+
message: A2AMessage,
|
|
75
|
+
metadata: dict[str, Any],
|
|
76
|
+
*,
|
|
77
|
+
timeout: float,
|
|
78
|
+
) -> AsyncIterator[A2AEvent]:
|
|
79
|
+
url = self.resolve_address(agent.canonical_name)
|
|
80
|
+
payload = self._build_payload("message/stream", message, metadata)
|
|
81
|
+
async with (
|
|
82
|
+
httpx.AsyncClient(timeout=timeout) as client,
|
|
83
|
+
client.stream("POST", url, json=payload) as response,
|
|
84
|
+
):
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
async for line in response.aiter_lines():
|
|
87
|
+
if not line or not line.startswith("data:"):
|
|
88
|
+
continue
|
|
89
|
+
data = line.removeprefix("data:").strip()
|
|
90
|
+
if not data:
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
parsed = json.loads(data)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
continue
|
|
96
|
+
# A2AEvent model_validate tolerates missing optional fields.
|
|
97
|
+
try:
|
|
98
|
+
yield A2AEvent.model_validate(parsed)
|
|
99
|
+
except ValidationError:
|
|
100
|
+
# Skip lines that don't match the A2AEvent shape (e.g.,
|
|
101
|
+
# legacy JSON-RPC envelope frames emitted by the
|
|
102
|
+
# LangChain adapter for token/status/final events).
|
|
103
|
+
# Existing SSE consumers can still parse the envelope
|
|
104
|
+
# themselves at a higher layer. Log at debug so this is
|
|
105
|
+
# visible if real bugs surface.
|
|
106
|
+
logger.debug("skipping non-A2AEvent SSE frame: %r", parsed)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
async def serve(self, canonical_name: str, handler: ServerDispatcherProtocol) -> None:
|
|
110
|
+
# FastAPI's /a2a route already handles inbound HTTP; nothing to do.
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def _agent_base_url(self, agent: AgentRef) -> str:
|
|
114
|
+
"""Derive the agent's base URL from its A2A wire address.
|
|
115
|
+
|
|
116
|
+
Plan A's URL format is consistent: the A2A endpoint lives at `{base}/a2a`,
|
|
117
|
+
so stripping the suffix gives the base. For future transports that use
|
|
118
|
+
different paths, revisit.
|
|
119
|
+
"""
|
|
120
|
+
a2a_url = self.resolve_address(agent.canonical_name)
|
|
121
|
+
return a2a_url.removesuffix("/a2a")
|
|
122
|
+
|
|
123
|
+
async def create_response(
|
|
124
|
+
self,
|
|
125
|
+
agent: AgentRef,
|
|
126
|
+
request: dict[str, Any],
|
|
127
|
+
metadata: dict[str, Any],
|
|
128
|
+
*,
|
|
129
|
+
timeout: float,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
base = self._agent_base_url(agent)
|
|
132
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
133
|
+
response = await client.post(f"{base}/v1/responses", json=request)
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
return response.json()
|
|
136
|
+
|
|
137
|
+
async def create_response_stream(
|
|
138
|
+
self,
|
|
139
|
+
agent: AgentRef,
|
|
140
|
+
request: dict[str, Any],
|
|
141
|
+
metadata: dict[str, Any],
|
|
142
|
+
*,
|
|
143
|
+
timeout: float,
|
|
144
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
145
|
+
base = self._agent_base_url(agent)
|
|
146
|
+
# Force stream=true even if caller didn't set it — this method's
|
|
147
|
+
# contract is that we yield chunks.
|
|
148
|
+
body = {**request, "stream": True}
|
|
149
|
+
async with (
|
|
150
|
+
httpx.AsyncClient(timeout=timeout) as client,
|
|
151
|
+
client.stream("POST", f"{base}/v1/responses", json=body) as response,
|
|
152
|
+
):
|
|
153
|
+
response.raise_for_status()
|
|
154
|
+
async for line in response.aiter_lines():
|
|
155
|
+
if not line or not line.startswith("data:"):
|
|
156
|
+
continue
|
|
157
|
+
data = line.removeprefix("data:").strip()
|
|
158
|
+
if not data or data == "[DONE]":
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
yield json.loads(data)
|
|
162
|
+
except json.JSONDecodeError:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
async def get_response(
|
|
166
|
+
self,
|
|
167
|
+
agent: AgentRef,
|
|
168
|
+
response_id: str,
|
|
169
|
+
*,
|
|
170
|
+
timeout: float,
|
|
171
|
+
) -> dict[str, Any] | None:
|
|
172
|
+
base = self._agent_base_url(agent)
|
|
173
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
174
|
+
response = await client.get(f"{base}/v1/responses/{response_id}")
|
|
175
|
+
if response.status_code == 404:
|
|
176
|
+
return None
|
|
177
|
+
response.raise_for_status()
|
|
178
|
+
return response.json()
|
|
179
|
+
|
|
180
|
+
def _build_payload(
|
|
181
|
+
self, method: str, message: A2AMessage, metadata: dict[str, Any]
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
# A2A v0.3 spec shape (`message/send`, `message/stream`). The SDK's
|
|
184
|
+
# v0.3 compat layer requires `kind: "message"` plus a non-empty
|
|
185
|
+
# `messageId` on the wire — using the correlation_id as the
|
|
186
|
+
# messageId keeps client/server callsites lined up for tracing.
|
|
187
|
+
# `kind: "text"` on each part is similarly required.
|
|
188
|
+
parts = [
|
|
189
|
+
{**(p if isinstance(p, dict) else {}), "kind": "text"}
|
|
190
|
+
for p in message.parts
|
|
191
|
+
]
|
|
192
|
+
return {
|
|
193
|
+
"jsonrpc": "2.0",
|
|
194
|
+
"id": str(uuid.uuid4()),
|
|
195
|
+
"method": method,
|
|
196
|
+
"params": {
|
|
197
|
+
"message": {
|
|
198
|
+
"kind": "message",
|
|
199
|
+
"messageId": message.correlation_id,
|
|
200
|
+
"role": message.role,
|
|
201
|
+
"parts": parts,
|
|
202
|
+
"contextId": message.correlation_id,
|
|
203
|
+
},
|
|
204
|
+
"metadata": {**message.metadata, **metadata},
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def _parse_result(self, body: dict[str, Any], fallback_correlation: str) -> A2AResult:
|
|
209
|
+
result = body.get("result", {}) or {}
|
|
210
|
+
parts = result.get("status", {}).get("message", {}).get("parts", [])
|
|
211
|
+
text = ""
|
|
212
|
+
for part in parts:
|
|
213
|
+
if isinstance(part, dict) and "text" in part:
|
|
214
|
+
text += part["text"]
|
|
215
|
+
return A2AResult(
|
|
216
|
+
text=text,
|
|
217
|
+
correlation_id=result.get("correlation_id") or fallback_correlation,
|
|
218
|
+
metadata={},
|
|
219
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""HttpChannelDelivery test."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from vystak_channel_runtime.delivery import DeliveryRequest
|
|
7
|
+
from vystak_transport_http.delivery import HttpChannelDelivery
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.asyncio
|
|
11
|
+
async def test_post_to_channel_url():
|
|
12
|
+
routes = {"x.channels.dev": "http://vystak-channel-x:9999"}
|
|
13
|
+
d = HttpChannelDelivery(routes)
|
|
14
|
+
with patch("vystak_transport_http.delivery.httpx.AsyncClient") as ac:
|
|
15
|
+
client = AsyncMock()
|
|
16
|
+
ac.return_value.__aenter__.return_value = client
|
|
17
|
+
response = MagicMock()
|
|
18
|
+
response.raise_for_status = MagicMock()
|
|
19
|
+
client.post = AsyncMock(return_value=response)
|
|
20
|
+
await d.deliver("x.channels.dev", DeliveryRequest(thread_id="t", text="x"))
|
|
21
|
+
client.post.assert_awaited_once()
|
|
22
|
+
url = client.post.call_args.args[0]
|
|
23
|
+
assert url == "http://vystak-channel-x:9999/deliver"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.asyncio
|
|
27
|
+
async def test_unknown_channel_raises():
|
|
28
|
+
d = HttpChannelDelivery({})
|
|
29
|
+
with pytest.raises(KeyError):
|
|
30
|
+
await d.deliver("ghost.channels.dev", DeliveryRequest(thread_id="t", text="x"))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tests for HttpTransportPlugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from vystak.schema import Platform, Transport
|
|
6
|
+
from vystak.schema.agent import Agent
|
|
7
|
+
from vystak.schema.model import Model
|
|
8
|
+
from vystak.schema.provider import Provider
|
|
9
|
+
from vystak_transport_http import HttpTransportPlugin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_type():
|
|
13
|
+
p = HttpTransportPlugin()
|
|
14
|
+
assert p.type == "http"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_no_provision_nodes():
|
|
18
|
+
p = HttpTransportPlugin()
|
|
19
|
+
t = Transport(name="default-http", type="http")
|
|
20
|
+
provider = Provider(name="docker", type="docker")
|
|
21
|
+
pl = Platform(name="main", type="docker", provider=provider, transport=t)
|
|
22
|
+
assert p.build_provision_nodes(t, pl) == []
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_env_contract():
|
|
26
|
+
p = HttpTransportPlugin()
|
|
27
|
+
t = Transport(name="default-http", type="http")
|
|
28
|
+
env = p.generate_env_contract(t, context={})
|
|
29
|
+
assert env["VYSTAK_TRANSPORT_TYPE"] == "http"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_no_listener_code():
|
|
33
|
+
p = HttpTransportPlugin()
|
|
34
|
+
t = Transport(name="default-http", type="http")
|
|
35
|
+
assert p.generate_listener_code(t) is None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_resolve_address_for_docker_dns():
|
|
39
|
+
"""resolve_address_for returns the Docker container name-matched URL."""
|
|
40
|
+
p = HttpTransportPlugin()
|
|
41
|
+
provider = Provider(name="docker", type="docker")
|
|
42
|
+
pl = Platform(name="main", type="docker", provider=provider, namespace="prod")
|
|
43
|
+
_openai = Provider(name="openai", type="openai", api_key_env="OPENAI_API_KEY")
|
|
44
|
+
model = Model(name="gpt-4o", model_name="gpt-4o", provider=_openai)
|
|
45
|
+
agent = Agent(name="my-agent", framework="langchain-python", default_model=model, port=9000)
|
|
46
|
+
url = p.resolve_address_for(agent, pl)
|
|
47
|
+
# Matches DockerAgentNode's container naming: `vystak-{agent_name}`.
|
|
48
|
+
assert url == "http://vystak-my-agent:9000/a2a"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_resolve_address_for_default_port():
|
|
52
|
+
"""resolve_address_for uses port 8000 when agent.port is None."""
|
|
53
|
+
p = HttpTransportPlugin()
|
|
54
|
+
provider = Provider(name="docker", type="docker")
|
|
55
|
+
pl = Platform(name="main", type="docker", provider=provider, namespace="default")
|
|
56
|
+
_openai = Provider(name="openai", type="openai", api_key_env="OPENAI_API_KEY")
|
|
57
|
+
model = Model(name="gpt-4o", model_name="gpt-4o", provider=_openai)
|
|
58
|
+
agent = Agent(name="worker", framework="langchain-python", default_model=model)
|
|
59
|
+
url = p.resolve_address_for(agent, pl)
|
|
60
|
+
assert url == "http://vystak-worker:8000/a2a"
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Tests for HttpTransport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
import uvicorn
|
|
10
|
+
from fastapi import FastAPI, Request
|
|
11
|
+
from sse_starlette.sse import EventSourceResponse
|
|
12
|
+
from vystak.transport import (
|
|
13
|
+
A2AMessage,
|
|
14
|
+
AgentRef,
|
|
15
|
+
)
|
|
16
|
+
from vystak.transport.contract import TransportContract
|
|
17
|
+
from vystak_transport_http import HttpTransport
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _build_app(handler) -> FastAPI:
|
|
21
|
+
"""Minimal FastAPI app exposing /a2a for the test agent.
|
|
22
|
+
|
|
23
|
+
``handler`` is a ``ServerDispatcherProtocol`` (per Plan C), so A2A calls
|
|
24
|
+
route via ``dispatch_a2a`` / ``dispatch_a2a_stream``.
|
|
25
|
+
"""
|
|
26
|
+
app = FastAPI()
|
|
27
|
+
|
|
28
|
+
@app.post("/a2a")
|
|
29
|
+
async def a2a_endpoint(request: Request):
|
|
30
|
+
body = await request.json()
|
|
31
|
+
params = body.get("params", {})
|
|
32
|
+
metadata = params.get("metadata", {})
|
|
33
|
+
msg_params = params.get("message", {})
|
|
34
|
+
message = A2AMessage(
|
|
35
|
+
role=msg_params.get("role", "user"),
|
|
36
|
+
parts=msg_params.get("parts", []),
|
|
37
|
+
# Phase 10: v0.3 spec puts the correlation/thread id on the
|
|
38
|
+
# message itself (`messageId`/`contextId`) rather than top-level
|
|
39
|
+
# `params.id`. Test fake reads either path for compatibility.
|
|
40
|
+
correlation_id=(
|
|
41
|
+
params.get("id")
|
|
42
|
+
or msg_params.get("messageId")
|
|
43
|
+
or metadata.get("correlation_id", "")
|
|
44
|
+
),
|
|
45
|
+
metadata=metadata,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if body.get("method") == "message/stream":
|
|
49
|
+
|
|
50
|
+
async def gen():
|
|
51
|
+
async for ev in handler.dispatch_a2a_stream(message, metadata):
|
|
52
|
+
yield {"data": ev.model_dump_json()}
|
|
53
|
+
|
|
54
|
+
return EventSourceResponse(gen())
|
|
55
|
+
|
|
56
|
+
result = await handler.dispatch_a2a(message, metadata)
|
|
57
|
+
return {
|
|
58
|
+
"jsonrpc": "2.0",
|
|
59
|
+
"id": body.get("id"),
|
|
60
|
+
"result": {
|
|
61
|
+
"status": {"message": {"parts": [{"text": result.text}]}},
|
|
62
|
+
"correlation_id": result.correlation_id,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return app
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@asynccontextmanager
|
|
70
|
+
async def _serve(app: FastAPI, port: int):
|
|
71
|
+
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
|
|
72
|
+
server = uvicorn.Server(config)
|
|
73
|
+
task = asyncio.create_task(server.serve())
|
|
74
|
+
# Wait for the server to bind.
|
|
75
|
+
for _ in range(100):
|
|
76
|
+
if server.started:
|
|
77
|
+
break
|
|
78
|
+
await asyncio.sleep(0.01)
|
|
79
|
+
try:
|
|
80
|
+
yield
|
|
81
|
+
finally:
|
|
82
|
+
server.should_exit = True
|
|
83
|
+
await task
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestHttpTransport(TransportContract):
|
|
87
|
+
"""Runs the shared transport contract against HttpTransport."""
|
|
88
|
+
|
|
89
|
+
@pytest.fixture
|
|
90
|
+
def serve_agent(self, unused_tcp_port):
|
|
91
|
+
@asynccontextmanager
|
|
92
|
+
async def _ctx(canonical_name: str, handler):
|
|
93
|
+
app = _build_app(handler)
|
|
94
|
+
async with _serve(app, unused_tcp_port):
|
|
95
|
+
client = HttpTransport(
|
|
96
|
+
routes={canonical_name: f"http://127.0.0.1:{unused_tcp_port}/a2a"}
|
|
97
|
+
)
|
|
98
|
+
yield client
|
|
99
|
+
|
|
100
|
+
return _ctx
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestHttpTransportBasics:
|
|
104
|
+
def test_type(self):
|
|
105
|
+
t = HttpTransport(routes={})
|
|
106
|
+
assert t.type == "http"
|
|
107
|
+
assert t.supports_streaming is True
|
|
108
|
+
|
|
109
|
+
def test_resolve_address_lookup(self):
|
|
110
|
+
t = HttpTransport(routes={"x.agents.default": "http://example:8000/a2a"})
|
|
111
|
+
assert t.resolve_address("x.agents.default") == "http://example:8000/a2a"
|
|
112
|
+
|
|
113
|
+
def test_resolve_address_unknown(self):
|
|
114
|
+
t = HttpTransport(routes={})
|
|
115
|
+
with pytest.raises(KeyError):
|
|
116
|
+
t.resolve_address("unknown.agents.default")
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
async def test_serve_is_noop(self):
|
|
120
|
+
t = HttpTransport(routes={})
|
|
121
|
+
# serve() returns immediately; the actual /a2a route is served by
|
|
122
|
+
# the generated agent's FastAPI app.
|
|
123
|
+
await t.serve("x.agents.default", handler=None)
|
|
124
|
+
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_create_response_posts_to_agent(self, unused_tcp_port):
|
|
127
|
+
"""create_response POSTs the request body to the agent's /v1/responses."""
|
|
128
|
+
from fastapi import FastAPI
|
|
129
|
+
from fastapi.responses import JSONResponse
|
|
130
|
+
|
|
131
|
+
received: dict = {}
|
|
132
|
+
app = FastAPI()
|
|
133
|
+
|
|
134
|
+
@app.post("/v1/responses")
|
|
135
|
+
async def handler(request: Request): # Request imported at module level
|
|
136
|
+
received["body"] = await request.json()
|
|
137
|
+
return JSONResponse(
|
|
138
|
+
{
|
|
139
|
+
"id": "resp-1",
|
|
140
|
+
"object": "response",
|
|
141
|
+
"created_at": 1,
|
|
142
|
+
"model": "vystak/test",
|
|
143
|
+
"output": [{"type": "message", "content": "hi"}],
|
|
144
|
+
"status": "completed",
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
async with _serve(app, unused_tcp_port):
|
|
149
|
+
routes = {"test.agents.default": f"http://127.0.0.1:{unused_tcp_port}/a2a"}
|
|
150
|
+
t = HttpTransport(routes=routes)
|
|
151
|
+
ref = AgentRef(canonical_name="test.agents.default")
|
|
152
|
+
result = await t.create_response(
|
|
153
|
+
ref, {"model": "vystak/test", "input": "hello"}, {}, timeout=5
|
|
154
|
+
)
|
|
155
|
+
assert result["id"] == "resp-1"
|
|
156
|
+
assert received["body"]["input"] == "hello"
|
|
157
|
+
|
|
158
|
+
@pytest.mark.asyncio
|
|
159
|
+
async def test_get_response_returns_none_on_404(self, unused_tcp_port):
|
|
160
|
+
from fastapi import FastAPI
|
|
161
|
+
from fastapi.responses import JSONResponse
|
|
162
|
+
|
|
163
|
+
app = FastAPI()
|
|
164
|
+
|
|
165
|
+
@app.get("/v1/responses/{response_id}")
|
|
166
|
+
async def handler(response_id: str):
|
|
167
|
+
if response_id == "missing":
|
|
168
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
169
|
+
return JSONResponse({"id": response_id, "object": "response"})
|
|
170
|
+
|
|
171
|
+
async with _serve(app, unused_tcp_port):
|
|
172
|
+
routes = {"test.agents.default": f"http://127.0.0.1:{unused_tcp_port}/a2a"}
|
|
173
|
+
t = HttpTransport(routes=routes)
|
|
174
|
+
ref = AgentRef(canonical_name="test.agents.default")
|
|
175
|
+
assert await t.get_response(ref, "missing", timeout=5) is None
|
|
176
|
+
got = await t.get_response(ref, "resp-1", timeout=5)
|
|
177
|
+
assert got["id"] == "resp-1"
|
|
178
|
+
|
|
179
|
+
def test_agent_base_url_strips_a2a(self):
|
|
180
|
+
routes = {"x.agents.default": "http://vystak-x:8000/a2a"}
|
|
181
|
+
t = HttpTransport(routes=routes)
|
|
182
|
+
ref = AgentRef(canonical_name="x.agents.default")
|
|
183
|
+
assert t._agent_base_url(ref) == "http://vystak-x:8000"
|
|
184
|
+
|
|
185
|
+
@pytest.mark.asyncio
|
|
186
|
+
async def test_stream_task_skips_non_a2a_event_frames(self, unused_tcp_port):
|
|
187
|
+
"""stream_task must silently skip SSE frames whose JSON does not
|
|
188
|
+
match the A2AEvent shape (e.g., legacy JSON-RPC envelope frames
|
|
189
|
+
from the LangChain adapter), rather than aborting the stream."""
|
|
190
|
+
app = FastAPI()
|
|
191
|
+
|
|
192
|
+
@app.post("/a2a")
|
|
193
|
+
async def a2a_endpoint(request: Request):
|
|
194
|
+
body = await request.json()
|
|
195
|
+
assert body.get("method") == "message/stream"
|
|
196
|
+
|
|
197
|
+
async def gen():
|
|
198
|
+
# Frame 1: valid A2AEvent (bare).
|
|
199
|
+
yield {"data": '{"type": "tool_call", "data": {"tool_name": "x"}}'}
|
|
200
|
+
# Frame 2: not an A2AEvent (no `type` field) -> ValidationError.
|
|
201
|
+
yield {"data": '{"jsonrpc": "2.0", "id": "n", "result": {}}'}
|
|
202
|
+
# Frame 3: valid A2AEvent (bare).
|
|
203
|
+
yield {"data": '{"type": "final", "text": "done", "final": true}'}
|
|
204
|
+
|
|
205
|
+
return EventSourceResponse(gen())
|
|
206
|
+
|
|
207
|
+
async with _serve(app, unused_tcp_port):
|
|
208
|
+
routes = {"test.agents.default": f"http://127.0.0.1:{unused_tcp_port}/a2a"}
|
|
209
|
+
t = HttpTransport(routes=routes)
|
|
210
|
+
ref = AgentRef(canonical_name="test.agents.default")
|
|
211
|
+
msg = A2AMessage(role="user", parts=[{"text": "x"}], metadata={})
|
|
212
|
+
|
|
213
|
+
received = []
|
|
214
|
+
async for ev in t.stream_task(ref, msg, {}, timeout=5):
|
|
215
|
+
received.append(ev)
|
|
216
|
+
|
|
217
|
+
assert len(received) == 2
|
|
218
|
+
assert received[0].type == "tool_call"
|
|
219
|
+
assert received[1].type == "final"
|