ruslanmv commited on
Commit
f26d6cd
·
1 Parent(s): d5548b2

Docker fix

Browse files
Files changed (2) hide show
  1. Dockerfile +55 -21
  2. app/services/validator_service.py +252 -44
Dockerfile CHANGED
@@ -1,34 +1,68 @@
1
- # syntax=docker/dockerfile:1
2
- FROM python:3.11-slim
 
 
3
 
4
- # --- base env ---
5
  ENV PYTHONDONTWRITEBYTECODE=1 \
6
  PYTHONUNBUFFERED=1 \
7
  PIP_NO_CACHE_DIR=1
8
 
9
- # --- system deps ---
10
- RUN apt-get update \
11
- && apt-get install -y --no-install-recommends ca-certificates curl \
12
- && rm -rf /var/lib/apt/lists/*
 
 
 
 
 
 
 
 
 
 
13
 
14
- # --- app dir ---
15
  WORKDIR /app
16
 
17
- # --- python deps (cache friendly layer) ---
18
- COPY requirements.txt ./
19
- RUN pip install --upgrade pip && pip install -r requirements.txt
 
 
 
 
 
 
20
 
21
- # --- copy app ---
22
  COPY . .
23
 
24
- # Hugging Face sets $PORT at runtime; keep a sane default for local runs
25
- ENV PORT=7860
26
- EXPOSE 7860
 
27
 
28
- # Optional: run as non-root
29
- # RUN useradd -ms /bin/bash appuser && chown -R appuser:appuser /app
30
- # USER appuser
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- # --- start (shell form so $PORT expands) ---
33
- # --proxy-headers is helpful behind HF’s proxy
34
- CMD uvicorn app.main:app --host 0.0.0.0 --port $PORT --proxy-headers
 
 
 
1
+ # syntax=docker/dockerfile:1.4
2
+
3
+ # --- Stage 1: Builder ---------------------------------------------------------
4
+ FROM python:3.11-slim AS builder
5
 
 
6
  ENV PYTHONDONTWRITEBYTECODE=1 \
7
  PYTHONUNBUFFERED=1 \
8
  PIP_NO_CACHE_DIR=1
9
 
10
+ WORKDIR /app
11
+
12
+ # Install deps once and build wheels for a reproducible, cacheable layer
13
+ COPY requirements.txt .
14
+ RUN pip wheel --no-cache-dir --wheel-dir /app/wheels -r requirements.txt
15
+
16
+
17
+ # --- Stage 2: Final Image -----------------------------------------------------
18
+ FROM python:3.11-slim
19
+
20
+ ENV PYTHONDONTWRITEBYTECODE=1 \
21
+ PYTHONUNBUFFERED=1 \
22
+ # Platforms like HF Spaces set PORT at runtime; default to 7860 for local
23
+ PORT=7860
24
 
 
25
  WORKDIR /app
26
 
27
+ # Minimal runtime deps (TLS certs for HTTPS calls, etc.)
28
+ RUN apt-get update \
29
+ && apt-get install -y --no-install-recommends ca-certificates \
30
+ && rm -rf /var/lib/apt/lists/*
31
+
32
+ # Install prebuilt wheels
33
+ COPY --from=builder /app/wheels /wheels
34
+ COPY --from=builder /app/requirements.txt .
35
+ RUN pip install --no-cache-dir /wheels/*
36
 
37
+ # Copy app source
38
  COPY . .
39
 
40
+ # Non-root for security
41
+ RUN useradd --create-home --shell /bin/bash appuser \
42
+ && chown -R appuser:appuser /app
43
+ USER appuser
44
 
45
+ # --- Ports commonly used with A2A agents -------------------------------------
46
+ # NOTE: EXPOSE is documentation; publishing happens via `-p host:container`.
47
+ # 443 → Recommended prod HTTPS port (JSON-RPC /rpc and websockets on TLS)
48
+ # 80 → HTTP (typically only to redirect → 443 behind a reverse proxy)
49
+ # 8080 → Very common app / agent port for /rpc during dev/staging
50
+ # 8000 → Uvicorn/Gunicorn defaults (also used in many Python stacks)
51
+ # 7860 → Popular in ML tooling & Hugging Face Spaces (default UI port here)
52
+ # 5000 → Flask default (frequent in prototypes and simple agents)
53
+ # 3000 → Node dev servers / proxy frontends around agents
54
+ # 8443 → Alternate TLS port (used in some k8s/ingress setups)
55
+ EXPOSE 443
56
+ EXPOSE 80
57
+ EXPOSE 8080
58
+ EXPOSE 8000
59
+ EXPOSE 7860
60
+ EXPOSE 5000
61
+ EXPOSE 3000
62
+ EXPOSE 8443
63
 
64
+ # --- Start command ------------------------------------------------------------
65
+ # Use shell form so ${PORT} expands at runtime (important on HF Spaces).
66
+ # --host 0.0.0.0 allows external connections
67
+ # --proxy-headers plays nice behind reverse proxies
68
+ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860} --proxy-headers"]
app/services/validator_service.py CHANGED
@@ -3,12 +3,16 @@
3
  A2A Validator service.
4
  - Provides /validator (UI) + /validator/agent-card (HTTP) routes.
5
  - Defines all Socket.IO event handlers.
 
 
 
6
  """
7
  from __future__ import annotations
8
 
9
  import logging
10
- from typing import Any
11
- from urllib.parse import urlparse, urlunparse
 
12
  from uuid import uuid4
13
 
14
  import bleach
@@ -63,6 +67,7 @@ if HAS_SOCKETIO:
63
  sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
64
  socketio_app = socketio.ASGIApp(sio)
65
  else:
 
66
  class _SioShim:
67
  async def emit(self, *a, **k): # no-op
68
  pass
@@ -70,6 +75,7 @@ else:
70
  def on(self, *a, **k):
71
  def _wrap(f):
72
  return f
 
73
  return _wrap
74
 
75
  event = on
@@ -90,13 +96,122 @@ STANDARD_HEADERS = {
90
  "accept-encoding",
91
  }
92
 
 
 
93
  # ==============================================================================
94
  # State Management
95
  # ==============================================================================
96
- clients: dict[str, tuple[httpx.AsyncClient, Any, Any]] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  # ==============================================================================
99
- # Helpers
100
  # ==============================================================================
101
  async def _emit_debug_log(sid: str, event_id: str, log_type: str, data: Any) -> None:
102
  await sio.emit("debug_log", {"type": log_type, "data": data, "id": event_id}, to=sid)
@@ -137,16 +252,15 @@ def get_card_resolver(client: httpx.AsyncClient, agent_card_url: str) -> Any:
137
  return A2ACardResolver(client, base_url, agent_card_path=card_path)
138
  return A2ACardResolver(client, base_url)
139
 
 
140
  # ==============================================================================
141
  # FastAPI Routes
142
  # ==============================================================================
143
 
144
- # FIX: Add a decorator to handle requests without a trailing slash
145
- @router.get("", response_class=HTMLResponse, include_in_schema=False) # Handles /validator
146
- @router.get("/", response_class=HTMLResponse) # Handles /validator/
147
  async def validator_ui(request: Request) -> HTMLResponse:
148
- """Serves the main validator UI page."""
149
- # This logic already correctly tries to find validator.html or a fallback
150
  for name in ("validator.html", "validator.hml"):
151
  try:
152
  return templates.TemplateResponse(name, {"request": request})
@@ -162,13 +276,14 @@ async def get_agent_card(request: Request) -> JSONResponse:
162
 
163
  If A2A SDK is installed, use its resolver.
164
  Otherwise, be lenient: follow redirects and probe common well-known paths.
 
165
  """
166
  # Parse request body
167
  try:
168
  request_data = await request.json()
169
- agent_url = (request_data.get("url") or "").strip()
170
  sid = request_data.get("sid")
171
- if not agent_url or not sid:
172
  return JSONResponse({"error": "Agent URL and SID are required."}, status_code=400)
173
  except Exception:
174
  return JSONResponse({"error": "Invalid request body."}, status_code=400)
@@ -190,17 +305,21 @@ async def get_agent_card(request: Request) -> JSONResponse:
190
  # Fetch the agent card
191
  try:
192
  async with httpx.AsyncClient(
193
- timeout=30.0,
194
  headers=custom_headers,
195
- follow_redirects=True, # <<< important for 3xx like 307 to /docs
 
196
  ) as client:
 
 
 
197
  if HAS_A2A:
198
- # Preferred path: let the resolver figure out the right card location
199
- card_resolver = get_card_resolver(client, agent_url)
200
- card = await card_resolver.get_agent_card()
201
  card_data = card.model_dump(exclude_none=True)
 
 
202
  else:
203
- # Fallback: try what the user typed first; if non-JSON, probe common paths
204
  tried: list[str] = []
205
 
206
  async def _try(url: str) -> dict[str, Any]:
@@ -209,45 +328,73 @@ async def get_agent_card(request: Request) -> JSONResponse:
209
  ctype = (r.headers.get("content-type") or "").lower()
210
  if "application/json" in ctype or ctype.endswith("+json"):
211
  return r.json()
212
- # If we got HTML or something else, raise to trigger probing
213
  raise ValueError(f"Non-JSON response (content-type={ctype or 'unknown'}) at {url}")
214
 
 
215
  try:
216
- card_data = await _try(agent_url)
217
  except Exception:
218
- # If the user pasted a base/root URL, probe common Agent Card paths on same host
219
- parsed = urlparse(agent_url)
220
- base = f"{parsed.scheme}://{parsed.netloc}"
221
  candidates = [
222
- agent_url, # original again (in case it became JSON after redirect)
223
  f"{base}/.well-known/agent.json",
224
  f"{base}/.well-known/ai-agent.json",
225
  f"{base}/agent-card",
226
  f"{base}/agent.json",
227
  ]
228
- err: Exception | None = None
229
- card_data = None
230
  for u in candidates:
231
- if u in tried:
232
  continue
233
  tried.append(u)
234
  try:
235
  card_data = await _try(u)
236
- agent_url = u # record the working URL
237
  break
238
  except Exception as e:
239
- err = e
240
- if card_data is None:
241
  raise RuntimeError(
242
- f"Could not find a JSON Agent Card at {agent_url} (last error: {err})"
243
  )
244
 
245
  # Validate locally
246
  validation_errors = validators.validate_agent_card(card_data) # type: ignore[arg-type]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  response = {
248
  "card": card_data,
249
  "validation_errors": validation_errors,
250
- "resolved_url": agent_url,
 
251
  }
252
  status = 200
253
 
@@ -261,6 +408,7 @@ async def get_agent_card(request: Request) -> JSONResponse:
261
  await _emit_debug_log(sid, "http-agent-card", "response", {"status": status, "payload": response})
262
  return JSONResponse(content=response, status_code=status)
263
 
 
264
  # ==============================================================================
265
  # Socket.IO Event Handlers
266
  # ==============================================================================
@@ -273,7 +421,7 @@ async def handle_connect(sid: str, environ: dict[str, Any]) -> None: # type: ig
273
  async def handle_disconnect(sid: str) -> None: # type: ignore[misc]
274
  logger.info(f"Client disconnected: {sid}")
275
  if sid in clients:
276
- httpx_client, _, _ = clients.pop(sid)
277
  await httpx_client.aclose()
278
  logger.info(f"Cleaned up client for {sid}")
279
 
@@ -281,8 +429,8 @@ async def handle_disconnect(sid: str) -> None: # type: ignore[misc]
281
  @sio.on("initialize_client")
282
  async def handle_initialize_client(sid: str, data: dict[str, Any]) -> None: # type: ignore[misc]
283
  """
284
- Prepare an A2A client for chat/streaming. If a2a is not installed, reply with a warning
285
- so the UI still proceeds (card viewing still works via HTTP).
286
  """
287
  if not HAS_A2A:
288
  await sio.emit(
@@ -292,20 +440,79 @@ async def handle_initialize_client(sid: str, data: dict[str, Any]) -> None: # t
292
  )
293
  return
294
 
295
- agent_card_url = data.get("url")
296
- custom_headers = data.get("customHeaders", {})
297
- if not agent_card_url:
298
  await sio.emit("client_initialized", {"status": "error", "message": "Agent URL is required."}, to=sid)
299
  return
300
 
 
 
 
 
 
 
 
301
  try:
302
- httpx_client = httpx.AsyncClient(timeout=600.0, headers=custom_headers)
303
- card_resolver = get_card_resolver(httpx_client, agent_card_url)
304
- card = await card_resolver.get_agent_card()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  a2a_client = A2AClient(httpx_client, agent_card=card)
306
- clients[sid] = (httpx_client, a2a_client, card)
307
  await sio.emit("client_initialized", {"status": "success"}, to=sid)
 
308
  except Exception as e:
 
309
  await sio.emit("client_initialized", {"status": "error", "message": str(e)}, to=sid)
310
 
311
 
@@ -324,7 +531,7 @@ async def handle_send_message(sid: str, json_data: dict[str, Any]) -> None: # t
324
  await sio.emit("agent_response", {"error": "Client not initialized.", "id": message_id}, to=sid)
325
  return
326
 
327
- _, a2a_client, card = clients[sid]
328
 
329
  message = Message(
330
  role=Role.user,
@@ -337,7 +544,7 @@ async def handle_send_message(sid: str, json_data: dict[str, Any]) -> None: # t
337
  message=message,
338
  configuration=MessageSendConfiguration(accepted_output_modes=["text/plain", "video/mp4"]),
339
  )
340
- supports_streaming = hasattr(card.capabilities, "streaming") and card.capabilities.streaming is True
341
 
342
  try:
343
  if supports_streaming:
@@ -358,4 +565,5 @@ async def handle_send_message(sid: str, json_data: dict[str, Any]) -> None: # t
358
  except Exception as e:
359
  await sio.emit("agent_response", {"error": f"Failed to send message: {e}", "id": message_id}, to=sid)
360
 
361
- __all__ = ["router", "socketio_app", "HAS_SOCKETIO"]
 
 
3
  A2A Validator service.
4
  - Provides /validator (UI) + /validator/agent-card (HTTP) routes.
5
  - Defines all Socket.IO event handlers.
6
+ - Automatic localhost rewriting: if an Agent Card's "url" is localhost/127.0.0.1,
7
+ we rewrite it to the origin that served the card (same scheme+host+port), then
8
+ probe connectivity. If that fails, we try host.docker.internal:<same-port>.
9
  """
10
  from __future__ import annotations
11
 
12
  import logging
13
+ import socket
14
+ from typing import Any, Mapping, Tuple
15
+ from urllib.parse import urlparse, urlunparse, ParseResult
16
  from uuid import uuid4
17
 
18
  import bleach
 
67
  sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
68
  socketio_app = socketio.ASGIApp(sio)
69
  else:
70
+
71
  class _SioShim:
72
  async def emit(self, *a, **k): # no-op
73
  pass
 
75
  def on(self, *a, **k):
76
  def _wrap(f):
77
  return f
78
+
79
  return _wrap
80
 
81
  event = on
 
96
  "accept-encoding",
97
  }
98
 
99
+ LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "[::1]"}
100
+
101
  # ==============================================================================
102
  # State Management
103
  # ==============================================================================
104
+ # sid -> (httpx_client, a2a_client, card, origin_used_for_card_fetch)
105
+ clients: dict[str, tuple[httpx.AsyncClient, Any, Any, str]] = {}
106
+
107
+ # ==============================================================================
108
+ # URL helpers / rewriting
109
+ # ==============================================================================
110
+ def _parse(url: str) -> ParseResult:
111
+ return urlparse(url)
112
+
113
+
114
+ def _build(pr: ParseResult) -> str:
115
+ return urlunparse(pr)
116
+
117
+
118
+ def _origin_of(url: str) -> str:
119
+ """
120
+ Return scheme://netloc for a URL (no path/query/fragment).
121
+ """
122
+ pr = _parse(url)
123
+ return f"{pr.scheme}://{pr.netloc}" if pr.scheme and pr.netloc else ""
124
+
125
+
126
+ def _looks_localhost(host: str | None) -> bool:
127
+ return (host or "").lower() in LOCAL_HOSTS
128
+
129
+
130
+ def _docker_has_host_gateway() -> bool:
131
+ try:
132
+ socket.gethostbyname("host.docker.internal")
133
+ return True
134
+ except Exception:
135
+ return False
136
+
137
+
138
+ def _rewrite_to_origin(card_url: ParseResult, card_origin: ParseResult) -> Tuple[ParseResult, str | None]:
139
+ """
140
+ If the card_url host is localhost/127.0.0.1 and we know the origin where the
141
+ Agent Card was fetched from, rewrite to that origin (same scheme+host[:port]),
142
+ preserving the /path?query.
143
+ """
144
+ if not _looks_localhost(card_url.hostname):
145
+ return card_url, None
146
+ if not (card_origin.scheme and card_origin.netloc):
147
+ return card_url, None
148
+
149
+ rewritten = ParseResult(
150
+ scheme=card_origin.scheme,
151
+ netloc=card_origin.netloc,
152
+ path=card_url.path or "",
153
+ params="",
154
+ query=card_url.query or "",
155
+ fragment="",
156
+ )
157
+ return rewritten, "rewritten to Agent Card origin"
158
+
159
+
160
+ def _rewrite_to_gateway(card_url: ParseResult) -> Tuple[ParseResult, str | None]:
161
+ """
162
+ Fallback: rewrite localhost to host.docker.internal:<same-port> if resolvable.
163
+ """
164
+ if not _looks_localhost(card_url.hostname):
165
+ return card_url, None
166
+ if not _docker_has_host_gateway():
167
+ return card_url, None
168
+
169
+ port = f":{card_url.port}" if card_url.port else ""
170
+ rewritten = ParseResult(
171
+ scheme=card_url.scheme or "http",
172
+ netloc=f"host.docker.internal{port}",
173
+ path=card_url.path or "",
174
+ params="",
175
+ query=card_url.query or "",
176
+ fragment="",
177
+ )
178
+ return rewritten, "rewritten via host.docker.internal"
179
+
180
+
181
+ async def _probe_reachable(client: httpx.AsyncClient, url: str) -> Tuple[bool, str]:
182
+ """
183
+ Cheap reachability probe.
184
+ - 2xx/3xx reachable
185
+ - 405 counts as reachable (JSON-RPC endpoints often reject GET)
186
+ """
187
+ try:
188
+ r = await client.get(url)
189
+ if r.status_code == 405:
190
+ return True, "reachable (405 on GET is OK for JSON-RPC)"
191
+ if 200 <= r.status_code < 400:
192
+ return True, f"reachable (HTTP {r.status_code})"
193
+ return False, f"HTTP {r.status_code}"
194
+ except httpx.ConnectError as e:
195
+ return False, f"connect error: {e!s}"
196
+ except httpx.RequestError as e:
197
+ return False, f"request error: {e!s}"
198
+ except Exception as e:
199
+ return False, f"unexpected error: {e!s}"
200
+
201
+
202
+ def _card_copy_with_url(card: AgentCard, new_url: str) -> AgentCard:
203
+ try:
204
+ return card.model_copy(update={"url": new_url}) # type: ignore[attr-defined]
205
+ except Exception:
206
+ try:
207
+ card.url = new_url # type: ignore[attr-defined]
208
+ return card
209
+ except Exception:
210
+ raise
211
+
212
 
213
  # ==============================================================================
214
+ # Debug helpers
215
  # ==============================================================================
216
  async def _emit_debug_log(sid: str, event_id: str, log_type: str, data: Any) -> None:
217
  await sio.emit("debug_log", {"type": log_type, "data": data, "id": event_id}, to=sid)
 
252
  return A2ACardResolver(client, base_url, agent_card_path=card_path)
253
  return A2ACardResolver(client, base_url)
254
 
255
+
256
  # ==============================================================================
257
  # FastAPI Routes
258
  # ==============================================================================
259
 
260
+ # Handle both /validator and /validator/
261
+ @router.get("", response_class=HTMLResponse, include_in_schema=False)
262
+ @router.get("/", response_class=HTMLResponse)
263
  async def validator_ui(request: Request) -> HTMLResponse:
 
 
264
  for name in ("validator.html", "validator.hml"):
265
  try:
266
  return templates.TemplateResponse(name, {"request": request})
 
276
 
277
  If A2A SDK is installed, use its resolver.
278
  Otherwise, be lenient: follow redirects and probe common well-known paths.
279
+ Automatically rewrite localhost URLs in the card to the card's own origin.
280
  """
281
  # Parse request body
282
  try:
283
  request_data = await request.json()
284
+ user_url = (request_data.get("url") or "").strip()
285
  sid = request_data.get("sid")
286
+ if not user_url or not sid:
287
  return JSONResponse({"error": "Agent URL and SID are required."}, status_code=400)
288
  except Exception:
289
  return JSONResponse({"error": "Invalid request body."}, status_code=400)
 
305
  # Fetch the agent card
306
  try:
307
  async with httpx.AsyncClient(
308
+ timeout=httpx.Timeout(30.0, connect=10.0),
309
  headers=custom_headers,
310
+ follow_redirects=True,
311
+ trust_env=True,
312
  ) as client:
313
+ # We'll remember the ORIGIN we used to reach the card, for rewriting.
314
+ card_fetch_origin = _origin_of(user_url)
315
+
316
  if HAS_A2A:
317
+ resolver = get_card_resolver(client, user_url)
318
+ card = await resolver.get_agent_card() # type: ignore[assignment]
 
319
  card_data = card.model_dump(exclude_none=True)
320
+ # Origin we used is the resolver's base (scheme+host[:port])
321
+ card_fetch_origin = _origin_of(user_url)
322
  else:
 
323
  tried: list[str] = []
324
 
325
  async def _try(url: str) -> dict[str, Any]:
 
328
  ctype = (r.headers.get("content-type") or "").lower()
329
  if "application/json" in ctype or ctype.endswith("+json"):
330
  return r.json()
 
331
  raise ValueError(f"Non-JSON response (content-type={ctype or 'unknown'}) at {url}")
332
 
333
+ # Try the user URL first; otherwise probe well-knowns at that origin
334
  try:
335
+ card_data = await _try(user_url)
336
  except Exception:
337
+ pr = _parse(user_url)
338
+ base = f"{pr.scheme}://{pr.netloc}" if pr.scheme and pr.netloc else ""
 
339
  candidates = [
340
+ user_url,
341
  f"{base}/.well-known/agent.json",
342
  f"{base}/.well-known/ai-agent.json",
343
  f"{base}/agent-card",
344
  f"{base}/agent.json",
345
  ]
346
+ last_err: Exception | None = None
347
+ card_data = None # type: ignore[assignment]
348
  for u in candidates:
349
+ if u in tried or not u.startswith("http"):
350
  continue
351
  tried.append(u)
352
  try:
353
  card_data = await _try(u)
354
+ card_fetch_origin = _origin_of(u)
355
  break
356
  except Exception as e:
357
+ last_err = e
358
+ if card_data is None: # type: ignore[truthy-bool]
359
  raise RuntimeError(
360
+ f"Could not find a JSON Agent Card at {user_url} (last error: {last_err})"
361
  )
362
 
363
  # Validate locally
364
  validation_errors = validators.validate_agent_card(card_data) # type: ignore[arg-type]
365
+
366
+ # --- Automatic localhost rewrite of the card's own URL ---
367
+ rewrite_note = None
368
+ resolved_card_url = None
369
+ try:
370
+ card_origin_pr = _parse(card_fetch_origin) if card_fetch_origin else None
371
+ raw_card_url = (card_data.get("url") if isinstance(card_data, Mapping) else None) or ""
372
+ card_url_pr = _parse(raw_card_url)
373
+
374
+ if _looks_localhost(card_url_pr.hostname):
375
+ # 1) Prefer rewrite to the origin that served the Agent Card
376
+ if card_origin_pr and (card_origin_pr.scheme and card_origin_pr.netloc):
377
+ new_pr, note = _rewrite_to_origin(card_url_pr, card_origin_pr)
378
+ if note:
379
+ resolved_card_url = _build(new_pr)
380
+ card_data = {**card_data, "url": resolved_card_url} # type: ignore[operator]
381
+ rewrite_note = note
382
+ # 2) Fallback to host.docker.internal if available
383
+ if not resolved_card_url:
384
+ new_pr, note = _rewrite_to_gateway(card_url_pr)
385
+ if note:
386
+ resolved_card_url = _build(new_pr)
387
+ card_data = {**card_data, "url": resolved_card_url} # type: ignore[operator]
388
+ rewrite_note = note
389
+ except Exception as e:
390
+ # Do not fail the card response if rewriting fails
391
+ rewrite_note = f"rewrite failed: {e}"
392
+
393
  response = {
394
  "card": card_data,
395
  "validation_errors": validation_errors,
396
+ "resolved_url": (card_data.get("url") if isinstance(card_data, Mapping) else None), # type: ignore[union-attr]
397
+ "rewrite_note": rewrite_note,
398
  }
399
  status = 200
400
 
 
408
  await _emit_debug_log(sid, "http-agent-card", "response", {"status": status, "payload": response})
409
  return JSONResponse(content=response, status_code=status)
410
 
411
+
412
  # ==============================================================================
413
  # Socket.IO Event Handlers
414
  # ==============================================================================
 
421
  async def handle_disconnect(sid: str) -> None: # type: ignore[misc]
422
  logger.info(f"Client disconnected: {sid}")
423
  if sid in clients:
424
+ httpx_client, _, _, _ = clients.pop(sid)
425
  await httpx_client.aclose()
426
  logger.info(f"Cleaned up client for {sid}")
427
 
 
429
  @sio.on("initialize_client")
430
  async def handle_initialize_client(sid: str, data: dict[str, Any]) -> None: # type: ignore[misc]
431
  """
432
+ Prepare an A2A client for chat/streaming.
433
+ Automatically rewrites localhost card URLs to the card origin or host.docker.internal.
434
  """
435
  if not HAS_A2A:
436
  await sio.emit(
 
440
  )
441
  return
442
 
443
+ user_url = (data.get("url") or "").strip()
444
+ custom_headers = data.get("customHeaders", {}) or {}
445
+ if not user_url:
446
  await sio.emit("client_initialized", {"status": "error", "message": "Agent URL is required."}, to=sid)
447
  return
448
 
449
+ httpx_client = httpx.AsyncClient(
450
+ timeout=httpx.Timeout(60.0, connect=15.0),
451
+ headers=custom_headers,
452
+ follow_redirects=True,
453
+ trust_env=True,
454
+ )
455
+
456
  try:
457
+ # Resolve card (this base will be our "origin" candidate)
458
+ resolver = get_card_resolver(httpx_client, user_url)
459
+ card: AgentCard = await resolver.get_agent_card() # type: ignore[assignment]
460
+ card_fetch_origin = _origin_of(user_url)
461
+ origin_pr = _parse(card_fetch_origin) if card_fetch_origin else None
462
+
463
+ # Rewrite card.url if it's localhost to the card origin first
464
+ try:
465
+ card_url_pr = _parse(getattr(card, "url", "") or "")
466
+ if _looks_localhost(card_url_pr.hostname):
467
+ if origin_pr and (origin_pr.scheme and origin_pr.netloc):
468
+ new_pr, note = _rewrite_to_origin(card_url_pr, origin_pr)
469
+ if note:
470
+ new_url = _build(new_pr)
471
+ card = _card_copy_with_url(card, new_url)
472
+ await _emit_debug_log(
473
+ sid,
474
+ "client-init-rewrite",
475
+ "info",
476
+ {"original": card_url_pr.geturl(), "rewritten": new_url, "note": note},
477
+ )
478
+ # Fallback to host.docker.internal if still localhost
479
+ card_url_pr2 = _parse(getattr(card, "url", "") or "")
480
+ if _looks_localhost(card_url_pr2.hostname):
481
+ new_pr, note = _rewrite_to_gateway(card_url_pr2)
482
+ if note:
483
+ new_url = _build(new_pr)
484
+ card = _card_copy_with_url(card, new_url)
485
+ await _emit_debug_log(
486
+ sid,
487
+ "client-init-gateway",
488
+ "info",
489
+ {"original": card_url_pr2.geturl(), "rewritten": new_url, "note": note},
490
+ )
491
+ except Exception as e:
492
+ await _emit_debug_log(sid, "client-init-rewrite", "warn", {"error": str(e)})
493
+
494
+ # Connectivity probe before enabling chat
495
+ ok, detail = await _probe_reachable(httpx_client, getattr(card, "url", ""))
496
+ if not ok:
497
+ await sio.emit(
498
+ "client_initialized",
499
+ {
500
+ "status": "error",
501
+ "message": f"Agent endpoint unreachable: {detail}. "
502
+ f"Ensure your Agent Card URL points to a network-reachable host.",
503
+ },
504
+ to=sid,
505
+ )
506
+ await httpx_client.aclose()
507
+ return
508
+
509
+ # Create A2A client and store
510
  a2a_client = A2AClient(httpx_client, agent_card=card)
511
+ clients[sid] = (httpx_client, a2a_client, card, card_fetch_origin)
512
  await sio.emit("client_initialized", {"status": "success"}, to=sid)
513
+
514
  except Exception as e:
515
+ await httpx_client.aclose()
516
  await sio.emit("client_initialized", {"status": "error", "message": str(e)}, to=sid)
517
 
518
 
 
531
  await sio.emit("agent_response", {"error": "Client not initialized.", "id": message_id}, to=sid)
532
  return
533
 
534
+ httpx_client, a2a_client, card, _origin = clients[sid]
535
 
536
  message = Message(
537
  role=Role.user,
 
544
  message=message,
545
  configuration=MessageSendConfiguration(accepted_output_modes=["text/plain", "video/mp4"]),
546
  )
547
+ supports_streaming = hasattr(card.capabilities, "streaming") and getattr(card.capabilities, "streaming") is True
548
 
549
  try:
550
  if supports_streaming:
 
565
  except Exception as e:
566
  await sio.emit("agent_response", {"error": f"Failed to send message: {e}", "id": message_id}, to=sid)
567
 
568
+
569
+ __all__ = ["router", "socketio_app", "HAS_SOCKETIO"]