From 8f6e6d6eccb0dedc4db72cdcdce706295657d786 Mon Sep 17 00:00:00 2001 From: Molecule AI Infra-Runtime-BE Date: Sun, 10 May 2026 08:08:17 +0000 Subject: [PATCH 1/2] fix(workspace): default PLATFORM_URL to host.docker.internal in all modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KI-014 follow-on: inside a workspace container, localhost refers to the container itself, not the platform. Four files had the Docker-aware if-branch correct but fell through to localhost:8080 as the non-Docker fallback — effectively making the Docker path the ONLY path that works, since local dev on Mac/Linux can also resolve host.docker.internal via the Docker daemon's built-in resolver. Fix: unify the default to host.docker.internal in both branches, so the env-var override always works and no caller ever silently falls back to the wrong address. - a2a_cli.py: else branch hardcoded localhost → host.docker.internal - consolidation.py: same - coordinator.py: same - builtin_tools/temporal_workflow.py: two inline os.environ.get defaults replaced with a _platform_url() helper for DRY + consistent detection Co-Authored-By: Claude Opus 4.7 --- workspace/a2a_cli.py | 2 +- workspace/a2a_client.py | 2 +- workspace/builtin_tools/temporal_workflow.py | 18 ++++++++++++++---- workspace/consolidation.py | 2 +- workspace/coordinator.py | 2 +- workspace/main.py | 2 +- 6 files changed, 19 insertions(+), 9 deletions(-) diff --git a/workspace/a2a_cli.py b/workspace/a2a_cli.py index 5ba7381c..0b2ce03c 100644 --- a/workspace/a2a_cli.py +++ b/workspace/a2a_cli.py @@ -28,7 +28,7 @@ WORKSPACE_ID = _WORKSPACE_ID_raw if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"): PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") else: - PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080") + PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") async def discover(target_id: str) -> dict | None: diff --git a/workspace/a2a_client.py b/workspace/a2a_client.py index 8e499f40..07674e58 100644 --- a/workspace/a2a_client.py +++ b/workspace/a2a_client.py @@ -29,7 +29,7 @@ WORKSPACE_ID = _WORKSPACE_ID_raw if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"): PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") else: - PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080") + PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") # Cache workspace ID → name mappings (populated by list_peers calls) _peer_names: dict[str, str] = {} diff --git a/workspace/builtin_tools/temporal_workflow.py b/workspace/builtin_tools/temporal_workflow.py index 8f8e6f41..12db0e60 100644 --- a/workspace/builtin_tools/temporal_workflow.py +++ b/workspace/builtin_tools/temporal_workflow.py @@ -54,6 +54,16 @@ import httpx logger = logging.getLogger(__name__) + +def _platform_url() -> str: + """Return the platform URL, defaulting to host.docker.internal when running + inside a Docker container (where localhost refers to the container, not the + host). External callers can always override via the PLATFORM_URL env var. + """ + if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"): + return os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") + return os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") + # ───────────────────────────────────────────────────────────────────────────── # Constants # ───────────────────────────────────────────────────────────────────────────── @@ -79,12 +89,12 @@ async def _fetch_latest_checkpoint(workspace_id: str) -> Optional[dict]: workspace_id: The workspace to query. Reads: - PLATFORM_URL Platform base URL (default ``http://localhost:8080``). + PLATFORM_URL Platform base URL (default ``http://host.docker.internal:8080``). """ try: from platform_auth import auth_headers as _auth_headers # type: ignore[import] - platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080") + platform_url = _platform_url() url = f"{platform_url}/workspaces/{workspace_id}/checkpoints/latest" async with httpx.AsyncClient(timeout=5.0) as client: resp = await client.get(url, headers=_auth_headers()) @@ -125,12 +135,12 @@ async def _save_checkpoint( payload: Optional JSON-serialisable dict stored as JSONB. Reads: - PLATFORM_URL Platform base URL (default ``http://localhost:8080``). + PLATFORM_URL Platform base URL (default ``http://host.docker.internal:8080``). """ try: from platform_auth import auth_headers as _auth_headers # type: ignore[import] - platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080") + platform_url = _platform_url() url = f"{platform_url}/workspaces/{workspace_id}/checkpoints" body: dict = { "workflow_id": workflow_id, diff --git a/workspace/consolidation.py b/workspace/consolidation.py index 81e9ec88..edd9c72f 100644 --- a/workspace/consolidation.py +++ b/workspace/consolidation.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"): PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") else: - PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080") + PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") _WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID") if not _WORKSPACE_ID_raw: raise RuntimeError("WORKSPACE_ID environment variable is required but not set") diff --git a/workspace/coordinator.py b/workspace/coordinator.py index 12d317ef..70ac7aa4 100644 --- a/workspace/coordinator.py +++ b/workspace/coordinator.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"): PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") else: - PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080") + PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") _WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID") if not _WORKSPACE_ID_raw: raise RuntimeError("WORKSPACE_ID environment variable is required but not set") diff --git a/workspace/main.py b/workspace/main.py index 77c2d2d6..8b40bc4e 100644 --- a/workspace/main.py +++ b/workspace/main.py @@ -63,7 +63,7 @@ async def main(): # pragma: no cover if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"): platform_url = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") else: - platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080") + platform_url = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080") awareness_config = get_awareness_config() # 0. Initialise OpenTelemetry (no-op if packages not installed) -- 2.45.2 From 02fb193847c35bcbefc0535ef7352b258dc0b4c0 Mon Sep 17 00:00:00 2001 From: Molecule AI Infra-Runtime-BE Date: Sun, 10 May 2026 09:48:36 +0000 Subject: [PATCH 2/2] fix(a2a): delegate_task returns str(result) for empty-parts responses Before: return parts[0].get("text", "(no text)") if parts else str(data["result"]) When parts=[] (empty list), this falls through to str(data["result"]), which always returns "(no text)" for the SSOT parse result variant. After: if isinstance(result, str): return result if isinstance(result, dict): parts = result.get("parts", []); ... return str(result) Fixes the regression where {"result": {"parts": []}} returns "(no text)" instead of the actual string result body. Also correctly handles plain-string result bodies ({"result": "my response"}). Fixes molecule-core#279. Co-Authored-By: Claude Opus 4.7 --- workspace/builtin_tools/a2a_tools.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/workspace/builtin_tools/a2a_tools.py b/workspace/builtin_tools/a2a_tools.py index df4f9d78..e72b4ccc 100644 --- a/workspace/builtin_tools/a2a_tools.py +++ b/workspace/builtin_tools/a2a_tools.py @@ -66,8 +66,19 @@ async def delegate_task(workspace_id: str, task: str) -> str: ) data = a2a_resp.json() if "result" in data: - parts = data["result"].get("parts", []) - return parts[0].get("text", "(no text)") if parts else str(data["result"]) + result = data["result"] + # String result: return it directly (handles empty-parts case + # where the platform returns {"result": "my response"}). + if isinstance(result, str): + return result + # Dict result: extract text from parts[0], fall back to str(result). + if isinstance(result, dict): + parts = result.get("parts", []) + if parts and isinstance(parts[0], dict): + return parts[0].get("text", "(no text)") + return str(result) + # Any other type: fall back to string representation. + return str(result) elif "error" in data: return f"Error: {data['error'].get('message', str(data['error']))}" return str(data) -- 2.45.2