Skip to content

Commit 1d3dffb

Browse files
committed
Use latest amber (v0.3.0)
1 parent 4d6b2e5 commit 1d3dffb

File tree

10 files changed

+215
-68
lines changed

10 files changed

+215
-68
lines changed

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
7+
permissions:
8+
contents: read
9+
10+
concurrency:
11+
group: ci-${{ github.workflow }}-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
test:
16+
runs-on: ubuntu-latest
17+
18+
steps:
19+
- name: Checkout repository
20+
uses: actions/checkout@v6
21+
22+
- name: Install uv
23+
uses: astral-sh/setup-uv@v7
24+
with:
25+
python-version: "3.13"
26+
enable-cache: true
27+
28+
- name: Run unit tests
29+
run: uv run --locked python -m unittest discover -s tests
30+
31+
- name: Verify source compiles
32+
run: uv run --locked python -m compileall src

.github/workflows/publish.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ jobs:
1616

1717
steps:
1818
- name: Checkout repository
19-
uses: actions/checkout@v4
19+
uses: actions/checkout@v6
2020

2121
- name: Log in to GitHub Container Registry
22-
uses: docker/login-action@v3
22+
uses: docker/login-action@v4
2323
with:
2424
registry: ghcr.io
2525
username: ${{ github.actor }}
@@ -37,7 +37,7 @@ jobs:
3737
3838
- name: Build and push Docker image
3939
id: build
40-
uses: docker/build-push-action@v5
40+
uses: docker/build-push-action@v7
4141
with:
4242
context: .
4343
file: Dockerfile

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22

33
Orchestrator and reverse proxy for AgentBeats assessments in Amber:
44
- initiates an assessment by sending participants and assessment configuration to the Green agent via A2A
5-
- rewrites participant URLs to route through itself
5+
- routes participant URLs through itself
66
- parses the Green agent's response and serves results as JSON
77

8+
The gateway now assumes Amber's current A2A routing model. It builds participant URLs from its own
9+
proxy address and rewrites loopback agent-card URLs so follow-up requests continue to flow through
10+
the gateway instead of leaking internal container addresses.
11+
812
The endpoint at `/` returns JSON with a `status` field set to `running` while the assessment is in progress. Once the session finishes, the endpoint returns `status` set to the final A2A task state (e.g. `completed`, `failed`) and `results` containing the parsed artifact data.
913

1014
## Configuration
@@ -13,7 +17,6 @@ The endpoint at `/` returns JSON with a `status` field set to `running` while th
1317
|---|---|---|
1418
| Env | `SERVICE_URLS` | JSON object mapping slot names to participant endpoint URLs (required) |
1519
| Env | `PARTICIPANT_ROLES` | JSON object mapping slot names to semantic role names (required) |
16-
| Env | `CALLBACK_URLS` | JSON object mapping slot names to the URL each participant uses to reach the gateway (required) |
1720
| Env | `ASSESSMENT_CONFIG` | Assessment parameters |
1821
| Arg | `--proxy-port` | Proxy server port (default: 8080) |
19-
| Arg | `--results-port` | Results server port (default: 8081) |
22+
| Arg | `--results-port` | Results server port (default: 8081) |

amber-manifest.json5

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
entrypoint: "uv run python src/main.py --proxy-port 8080 --results-port 8081",
66
env: {
77
SERVICE_URLS: "${slots}",
8-
CALLBACK_URLS: "${config.callback_urls}",
98
PARTICIPANT_ROLES: "${config.participant_roles}",
109
ASSESSMENT_CONFIG: "${config.assessment_config}",
1110
},
@@ -21,13 +20,11 @@
2120
properties: {
2221
assessment_config: { type: "object" },
2322
participant_roles: { type: "object" },
24-
callback_urls: { type: "object" },
2523
},
26-
required: ["assessment_config", "participant_roles", "callback_urls"],
24+
required: ["assessment_config", "participant_roles"],
2725
},
2826
slots: {
2927
green: { kind: "a2a" },
30-
green_mcp: { kind: "mcp", optional: true },
3128
purple1: { kind: "a2a" },
3229
purple2: { kind: "a2a", optional: true },
3330
purple3: { kind: "a2a", optional: true },
@@ -39,4 +36,4 @@
3936
results: { kind: "http", endpoint: "results" },
4037
},
4138
exports: { proxy: "proxy", results: "results" },
42-
}
39+
}

examples/debate/scenario.json5

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,6 @@
2222
purple1: "pro_debater",
2323
purple2: "con_debater",
2424
},
25-
callback_urls: {
26-
green: "${bindings.green-proxy.url}",
27-
},
2825
},
2926
},
3027
green: {
@@ -50,7 +47,7 @@
5047
{ to: "#gateway.green", from: "#green.a2a" },
5148
{ to: "#gateway.purple1", from: "#pro_debater.a2a" },
5249
{ to: "#gateway.purple2", from: "#con_debater.a2a" },
53-
{ to: "#green.proxy", from: "#gateway.proxy", weak: true, name: "green-proxy" },
50+
{ to: "#green.proxy", from: "#gateway.proxy", weak: true },
5451
],
5552
exports: { results: "#gateway.results" },
5653
metadata: {

src/config.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ class Config:
1010
results_port: int
1111
service_urls: dict[str, str] # slot name -> participant URL
1212
participant_roles: dict[str, str] # slot name -> agent role name
13-
callback_urls: dict[str, str] # slot name -> gateway URL (reverse bindings)
1413
assessment_config: dict
1514

1615

@@ -33,11 +32,6 @@ def load_config() -> Config:
3332
raise ValueError("PARTICIPANT_ROLES is required")
3433
participant_roles = json.loads(participant_roles_raw)
3534

36-
callback_urls_raw = os.environ.get("CALLBACK_URLS")
37-
if not callback_urls_raw:
38-
raise ValueError("CALLBACK_URLS is required")
39-
callback_urls = json.loads(callback_urls_raw)
40-
4135
assessment_config_raw = os.environ.get("ASSESSMENT_CONFIG", "{}")
4236
assessment_config = json.loads(assessment_config_raw)
4337

@@ -46,6 +40,5 @@ def load_config() -> Config:
4640
results_port=args.results_port,
4741
service_urls=service_urls,
4842
participant_roles=participant_roles,
49-
callback_urls=callback_urls,
5043
assessment_config=assessment_config,
5144
)

src/main.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ async def check_endpoint(endpoint: str) -> bool:
2424
resolver = A2ACardResolver(httpx_client=client, base_url=endpoint)
2525
await resolver.get_agent_card()
2626
return True
27-
except Exception as e:
27+
except Exception:
2828
# Any exception means the agent is not ready
2929
return False
3030

@@ -60,16 +60,15 @@ async def run_assessment_task(config):
6060
global result_data
6161

6262
print("Waiting for agents to be ready...")
63-
a2a_urls = [url for key, url in config.service_urls.items() if not key.endswith(("_http", "_mcp"))]
64-
ready = await wait_for_agents(a2a_urls)
63+
ready = await wait_for_agents(list(config.service_urls.values()))
6564
if not ready:
6665
result_data = {"status": "failed", "error": "Timeout: agents not ready"}
6766
return
6867

6968
print("Starting assessment.")
7069

7170
green_url = config.service_urls["green"]
72-
gateway_url = config.callback_urls["green"]
71+
gateway_url = f"http://127.0.0.1:{config.proxy_port}"
7372
participants = {
7473
role: f"{gateway_url}/{role}"
7574
for slot, role in config.participant_roles.items()
@@ -88,19 +87,8 @@ async def main():
8887
agent_routes = {
8988
role: config.service_urls[slot]
9089
for slot, role in config.participant_roles.items()
91-
if slot in config.service_urls
9290
}
93-
for slot, url in config.service_urls.items():
94-
if slot.endswith(("_http", "_mcp")):
95-
agent_routes[slot] = url
96-
97-
role_to_slot = {role: slot for slot, role in config.participant_roles.items()}
98-
99-
proxy = Proxy(
100-
agent_routes=agent_routes,
101-
callback_urls=config.callback_urls,
102-
role_to_slot=role_to_slot,
103-
)
91+
proxy = Proxy(agent_routes=agent_routes)
10492

10593
proxy_server = uvicorn.Server(
10694
uvicorn.Config(proxy.app, host="0.0.0.0", port=config.proxy_port, log_level="info")
@@ -117,4 +105,4 @@ async def main():
117105

118106

119107
if __name__ == "__main__":
120-
asyncio.run(main())
108+
asyncio.run(main())

src/proxy.py

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import re
21
import json
32
from contextlib import asynccontextmanager
3+
from urllib.parse import urlparse, urlunparse
44

55
import httpx
66
from starlette.applications import Starlette
@@ -9,35 +9,69 @@
99
from starlette.routing import Route
1010

1111

12-
_LOCALHOST_RE = re.compile(r'https?://(?:localhost|127\.0\.0\.1)(:\d+)?')
12+
def _rewrite_local_url(url: str, route: str, gateway_base_url: str) -> str | None:
13+
parsed = urlparse(url)
14+
if parsed.scheme not in {"http", "https"} or parsed.hostname not in {
15+
"localhost",
16+
"127.0.0.1",
17+
"0.0.0.0",
18+
"::1",
19+
}:
20+
return None
21+
22+
gateway_base = gateway_base_url.rstrip("/")
23+
rewritten_path = f"/{route}{parsed.path}" if parsed.path else f"/{route}"
24+
rewritten = urlparse(f"{gateway_base}{rewritten_path}")
25+
return urlunparse(
26+
(
27+
rewritten.scheme,
28+
rewritten.netloc,
29+
rewritten.path,
30+
parsed.params,
31+
parsed.query,
32+
parsed.fragment,
33+
)
34+
)
1335

1436

15-
def _rewrite_agent_card(body: bytes, route: str, green_callback_url: str) -> bytes:
16-
"""Rewrite the agent card's url field to point back through the gateway (green's perspective)."""
37+
def _rewrite_agent_card(body: bytes, route: str, gateway_base_url: str) -> bytes:
38+
"""Rewrite loopback agent-card URLs so follow-up calls come back through the gateway."""
1739
try:
1840
card = json.loads(body)
1941
except json.JSONDecodeError:
2042
return body
21-
if "url" in card:
22-
card["url"] = f"{green_callback_url}/{route}"
23-
return json.dumps(card).encode()
2443

44+
rewritten = False
45+
46+
if isinstance(card.get("supportedInterfaces"), list):
47+
for interface in card["supportedInterfaces"]:
48+
if not isinstance(interface, dict):
49+
continue
50+
raw_url = interface.get("url")
51+
if not isinstance(raw_url, str):
52+
continue
53+
updated_url = _rewrite_local_url(raw_url, route, gateway_base_url)
54+
if updated_url and updated_url != raw_url:
55+
interface["url"] = updated_url
56+
rewritten = True
57+
58+
raw_url = card.get("url")
59+
if isinstance(raw_url, str):
60+
updated_url = _rewrite_local_url(raw_url, route, gateway_base_url)
61+
if updated_url and updated_url != raw_url:
62+
card["url"] = updated_url
63+
rewritten = True
64+
65+
if not rewritten:
66+
return body
2567

26-
def _rewrite_localhost_urls(body: bytes, target_callback_url: str) -> bytes:
27-
"""Replace localhost URL bases in the request body with the target's callback URL."""
28-
return _LOCALHOST_RE.sub(target_callback_url.rstrip("/"), body.decode()).encode()
68+
card.pop("signatures", None)
69+
return json.dumps(card).encode()
2970

3071

3172
class Proxy:
32-
def __init__(
33-
self,
34-
agent_routes: dict[str, str], # route key -> upstream URL
35-
callback_urls: dict[str, str], # slot -> callback URL (includes "green")
36-
role_to_slot: dict[str, str], # role name -> slot name
37-
):
73+
def __init__(self, agent_routes: dict[str, str]): # route key -> upstream URL
3874
self.agent_routes = agent_routes
39-
self.callback_urls = callback_urls
40-
self.role_to_slot = role_to_slot
4175

4276
@asynccontextmanager
4377
async def lifespan(app):
@@ -70,13 +104,6 @@ async def handle_request(self, request: Request) -> Response:
70104

71105
body = await request.body()
72106

73-
slot = self.role_to_slot.get(name, name)
74-
base_callback = self.callback_urls.get(slot)
75-
if base_callback:
76-
body = _rewrite_localhost_urls(body, f"{base_callback}/green_mcp")
77-
elif body and _LOCALHOST_RE.search(body.decode()):
78-
print(f"Warning: no callback URL for {slot}, skipping localhost URL rewrite")
79-
80107
req = self.client.build_request(
81108
method=request.method,
82109
url=target_url,
@@ -87,9 +114,17 @@ async def handle_request(self, request: Request) -> Response:
87114
try:
88115
if path == ".well-known/agent-card.json":
89116
resp = await self.client.send(req)
90-
green_callback = self.callback_urls.get("green", "")
91-
response_body = _rewrite_agent_card(resp.content, name, green_callback)
92-
headers = {k: v for k, v in resp.headers.items() if k.lower() != "content-length"}
117+
gateway_base_url = str(request.base_url).rstrip("/")
118+
response_body = _rewrite_agent_card(
119+
resp.content,
120+
name,
121+
gateway_base_url,
122+
)
123+
headers = {
124+
k: v
125+
for k, v in resp.headers.items()
126+
if k.lower() not in {"content-length", "content-encoding"}
127+
}
93128
return Response(
94129
content=response_body,
95130
status_code=resp.status_code,
@@ -106,7 +141,11 @@ async def stream_body():
106141
return StreamingResponse(
107142
content=stream_body(),
108143
status_code=resp.status_code,
109-
headers=dict(resp.headers),
144+
headers={
145+
k: v
146+
for k, v in resp.headers.items()
147+
if k.lower() not in {"content-length", "content-encoding"}
148+
},
110149
)
111150
except httpx.ConnectError:
112151
return Response(f"Failed to connect to upstream: {name}", status_code=502)

tests/test_config.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import os
2+
import sys
3+
import unittest
4+
from pathlib import Path
5+
from unittest.mock import patch
6+
7+
8+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
9+
10+
from config import load_config
11+
12+
13+
class LoadConfigTests(unittest.TestCase):
14+
def test_load_config_reads_required_fields(self):
15+
with patch.dict(
16+
os.environ,
17+
{
18+
"SERVICE_URLS": '{"green":{"url":"http://green"},"purple1":{"url":"http://purple"}}',
19+
"PARTICIPANT_ROLES": '{"green":"judge","purple1":"debater"}',
20+
"ASSESSMENT_CONFIG": '{"topic":"test"}',
21+
},
22+
clear=True,
23+
), patch.object(sys, "argv", ["gateway"]):
24+
config = load_config()
25+
26+
self.assertEqual(
27+
config.service_urls,
28+
{"green": "http://green", "purple1": "http://purple"},
29+
)
30+
self.assertEqual(
31+
config.participant_roles,
32+
{"green": "judge", "purple1": "debater"},
33+
)
34+
self.assertEqual(config.assessment_config, {"topic": "test"})
35+
36+
37+
if __name__ == "__main__":
38+
unittest.main()

0 commit comments

Comments
 (0)