From 030f53beb483139ec3355196315fc03c4615f4b0 Mon Sep 17 00:00:00 2001 From: Hector Valverde Date: Mon, 29 Jun 2026 16:23:27 +0200 Subject: [PATCH 1/3] feat: add remote runtime state bridge --- coral/agent/__init__.py | 5 ++ coral/agent/manager.py | 39 +++++++++ coral/agent/remote.py | 152 +++++++++++++++++++++++++++++++++++ coral/cli/start.py | 22 +++++ coral/config.py | 31 +++++++ coral/web/api.py | 2 + tests/test_config.py | 20 +++++ tests/test_remote_runtime.py | 113 ++++++++++++++++++++++++++ 8 files changed, 384 insertions(+) create mode 100644 coral/agent/remote.py create mode 100644 tests/test_remote_runtime.py diff --git a/coral/agent/__init__.py b/coral/agent/__init__.py index 8adb4781..8c11817c 100644 --- a/coral/agent/__init__.py +++ b/coral/agent/__init__.py @@ -5,6 +5,7 @@ from coral.agent.builtin.opencode import OpenCodeRuntime from coral.agent.manager import AgentManager from coral.agent.registry import get_runtime, register_runtime +from coral.agent.remote import RemoteAgentHandle, RemoteAgentSpec, RemoteAgentState, RemoteRuntime from coral.agent.runtime import AgentRuntime __all__ = [ @@ -13,6 +14,10 @@ "ClaudeCodeRuntime", "CodexRuntime", "OpenCodeRuntime", + "RemoteAgentHandle", + "RemoteAgentSpec", + "RemoteAgentState", + "RemoteRuntime", "get_runtime", "register_runtime", ] diff --git a/coral/agent/manager.py b/coral/agent/manager.py index f28a6871..448854aa 100644 --- a/coral/agent/manager.py +++ b/coral/agent/manager.py @@ -37,6 +37,7 @@ choose_roster_balanced_subset, ) from coral.agent.registry import get_runtime +from coral.agent.remote import RemoteStateBridge, load_remote_runtime from coral.agent.runtime import AgentHandle, AgentRuntime from coral.agent.state import ( AgentRuntimeState, @@ -157,6 +158,8 @@ def __init__( self._pending_restart_after_pause: set[str] = set() self._gateway: Any | None = None self._gateway_keys: dict[str, str] = {} # agent_id -> proxy key + self._remote_state_bridge: RemoteStateBridge | None = None + self._last_remote_state_sync = 0.0 self._grader_proc: multiprocessing.Process | None = None self._grader_stop_event: Any | None = None # multiprocessing.Event # Island migration. Only meaningful with >=2 islands and migration @@ -211,6 +214,11 @@ def start_all(self) -> list[AgentHandle]: logger.info(f" coral_dir: {self.paths.coral_dir}") logger.info(f" repo_dir: {self.paths.repo_dir}") + # Optional remote runtime state bridge. This is additive: local + # subprocess agents keep their current worktree lifecycle. + self._start_remote_state_bridge_if_configured() + self._sync_remote_state(force=True) + # 1b. Start gateway if configured self._start_gateway_if_enabled() @@ -398,6 +406,35 @@ def _start_gateway_if_enabled(self) -> None: self._gateway = gateway logger.info(f"Gateway running at {gateway.url}") + def _start_remote_state_bridge_if_configured(self) -> None: + """Initialize the remote state bridge when agents.remote_runtime is configured.""" + assert self.paths is not None + remote_cfg = self.config.agents.remote_runtime + if not remote_cfg.class_path: + return + + runtime = load_remote_runtime(remote_cfg.class_path, remote_cfg.config) + self._remote_state_bridge = RemoteStateBridge( + runtime=runtime, + state_dir=self.paths.coral_dir / "public" / "remote_state", + ) + logger.info("Remote runtime state bridge enabled: %s", remote_cfg.class_path) + + def _sync_remote_state(self, force: bool = False) -> None: + """Best-effort sync from the configured remote runtime into public state.""" + if self._remote_state_bridge is None: + return + interval = self.config.agents.remote_runtime.sync_interval_seconds + now = time.time() + if not force and now - self._last_remote_state_sync < interval: + return + try: + states = self._remote_state_bridge.sync_once() + self._last_remote_state_sync = now + logger.debug("Synced %d remote agent state snapshot(s)", len(states)) + except Exception as e: + logger.warning("Remote runtime state sync failed: %s", e) + def _run_warmstart_research( self, warmstart: WarmStartRunner, @@ -2093,6 +2130,8 @@ def _signal_handler(sig: int, frame: Any) -> None: logger.info(f"Monitoring {len(self.handles)} agent(s) (check every {check_interval}s)...") while self._running: + self._sync_remote_state() + # Check for new attempts current_attempts = self._get_seen_attempts() new_attempts = current_attempts - seen_attempts diff --git a/coral/agent/remote.py b/coral/agent/remote.py new file mode 100644 index 00000000..747a69ce --- /dev/null +++ b/coral/agent/remote.py @@ -0,0 +1,152 @@ +"""Remote agent runtime contracts and state bridge helpers.""" + +from __future__ import annotations + +import importlib +import json +import os +from dataclasses import asdict, dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Protocol, runtime_checkable + + +def utc_now_iso() -> str: + """Return the current UTC timestamp in ISO-8601 format.""" + return datetime.now(UTC).isoformat() + + +@dataclass(frozen=True) +class RemoteAgentSpec: + """Description of an agent CORAL wants a remote runtime to deploy.""" + + agent_id: str + name: str + code_path: Path | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data["code_path"] = str(self.code_path) if self.code_path else None + return data + + +@dataclass(frozen=True) +class RemoteAgentHandle: + """Stable pointer to an agent managed by a remote runtime.""" + + agent_id: str + runtime_type: str + runtime_id: str + endpoint: str | None = None + status: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class RemoteAgentState: + """Normalized state collected from a remote runtime.""" + + agent_id: str + runtime_type: str + runtime_id: str + status: str + collected_at: str = field(default_factory=utc_now_iso) + metrics: dict[str, Any] = field(default_factory=dict) + artifacts: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@runtime_checkable +class RemoteRuntime(Protocol): + """Protocol for agents that run outside CORAL-managed local worktrees.""" + + runtime_type: str + + def deploy(self, agent_spec: RemoteAgentSpec) -> RemoteAgentHandle: + """Deploy or bind a remote agent and return its handle.""" + + def invoke(self, handle: RemoteAgentHandle, payload: dict[str, Any]) -> dict[str, Any]: + """Invoke a remote agent.""" + + def list_agents(self) -> list[RemoteAgentHandle]: + """Return known remote agents.""" + + def collect_metrics(self) -> list[RemoteAgentState]: + """Return normalized state for known remote agents.""" + + +def load_remote_runtime(class_path: str, config: dict[str, Any] | None = None) -> RemoteRuntime: + """Load a remote runtime adapter from ``module:Class`` notation.""" + if class_path.count(":") != 1: + raise ValueError( + f"Remote runtime class must be 'module.path:ClassName', got {class_path!r}" + ) + module_name, class_name = class_path.split(":", 1) + if not module_name or not class_name: + raise ValueError( + f"Remote runtime class must be 'module.path:ClassName', got {class_path!r}" + ) + + module = importlib.import_module(module_name) + runtime_class = getattr(module, class_name) + runtime_config = config or {} + from_config = getattr(runtime_class, "from_config", None) + runtime = from_config(runtime_config) if callable(from_config) else runtime_class(**runtime_config) + if not isinstance(runtime, RemoteRuntime): + raise TypeError( + f"{class_path} does not satisfy the RemoteRuntime protocol " + "(see coral/agent/remote.py for the required methods)." + ) + return runtime + + +class RemoteStateBridge: + """Persist remote runtime state under ``.coral/public/remote_state``.""" + + def __init__(self, runtime: RemoteRuntime, state_dir: Path) -> None: + self.runtime = runtime + self.state_dir = state_dir + + def sync_once(self) -> list[RemoteAgentState]: + """Collect metrics once and atomically write state files.""" + states = self.runtime.collect_metrics() + serialized = [state.to_dict() for state in states] + self.state_dir.mkdir(parents=True, exist_ok=True) + + for state in serialized: + self._write_json(self.state_dir / f"{state['agent_id']}.json", state) + + self._write_json( + self.state_dir / "index.json", + { + "runtime_type": self.runtime.runtime_type, + "collected_at": utc_now_iso(), + "agents": serialized, + }, + ) + return states + + @staticmethod + def _write_json(path: Path, payload: dict[str, Any]) -> None: + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + os.replace(tmp_path, path) + + +def read_remote_state(coral_dir: str | Path) -> dict[str, Any] | None: + """Best-effort read of the remote state index for CLI and web status.""" + path = Path(coral_dir) / "public" / "remote_state" / "index.json" + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None diff --git a/coral/cli/start.py b/coral/cli/start.py index a3260b2e..9b2c2400 100644 --- a/coral/cli/start.py +++ b/coral/cli/start.py @@ -838,6 +838,7 @@ def cmd_stop(args: argparse.Namespace) -> None: def cmd_status(args: argparse.Namespace) -> None: """Show agent status and leaderboard.""" + from coral.agent.remote import read_remote_state from coral.hub.attempts import ( format_leaderboard, format_status_summary, @@ -990,6 +991,27 @@ def cmd_status(args: argparse.Namespace) -> None: f"| grader-error rate: {rate_str}" ) + remote_state = read_remote_state(coral_dir) + if remote_state: + remote_agents = remote_state.get("agents", []) + if isinstance(remote_agents, list): + print(f"\nRemote agents: {len(remote_agents)}") + for agent in remote_agents: + if not isinstance(agent, dict): + continue + metrics = agent.get("metrics") if isinstance(agent.get("metrics"), dict) else {} + metric_text = "" + if metrics: + metric_text = " | " + ", ".join( + f"{key}={value}" for key, value in sorted(metrics.items()) + ) + print( + f" {agent.get('agent_id', 'unknown')}: " + f"{agent.get('status', 'unknown')} | " + f"{agent.get('runtime_type', remote_state.get('runtime_type', 'remote'))}" + f"{metric_text}" + ) + direction = read_direction(coral_dir) print() show_all = getattr(args, "all", False) diff --git a/coral/config.py b/coral/config.py index b11ae1d7..63144491 100644 --- a/coral/config.py +++ b/coral/config.py @@ -112,6 +112,22 @@ class WarmStartConfig: enabled: bool = False +@dataclass +class RemoteRuntimeConfig: + """Optional adapter for state from agents running in a remote runtime.""" + + class_path: str = "" # YAML key: agents.remote_runtime.class + config: dict[str, Any] = field(default_factory=dict) + sync_interval_seconds: int = 30 + + def __post_init__(self) -> None: + if self.sync_interval_seconds < 1: + raise ValueError( + "agents.remote_runtime.sync_interval_seconds must be >= 1, " + f"got {self.sync_interval_seconds}" + ) + + @dataclass class AgentAssignmentConfig: """Per-assignment override of runtime/model for mix-and-match multi-agent runs. @@ -145,6 +161,7 @@ class AgentConfig: model: str = "sonnet" gateway: GatewayConfig = field(default_factory=GatewayConfig) warmstart: WarmStartConfig = field(default_factory=WarmStartConfig) + remote_runtime: RemoteRuntimeConfig = field(default_factory=RemoteRuntimeConfig) runtime_options: dict[str, Any] = field(default_factory=dict) # OS-user isolation: when set (e.g. "agent"), the agent subprocess is run as # this unprivileged user while the manager/grader stay root. The agent's @@ -200,6 +217,8 @@ class AgentConfig: min_clean_runtime_seconds: int = 60 def __post_init__(self) -> None: + if isinstance(self.remote_runtime, dict): + self.remote_runtime = RemoteRuntimeConfig(**self.remote_runtime) # Reject negative values for the new reliability knobs; # 0 is treated as "disabled" for the same fields where it makes sense. for field_name in ( @@ -389,6 +408,11 @@ def to_dict(self) -> dict[str, Any]: container: dict[str, Any] = OmegaConf.to_container(sc, resolve=True) # type: ignore[assignment] # Remove internal-only fields container.pop("task_dir", None) + remote_runtime = container.get("agents", {}).get("remote_runtime") + if isinstance(remote_runtime, dict): + class_path = remote_runtime.pop("class_path", "") + if class_path: + remote_runtime["class"] = class_path # Serialize heartbeat is_global as "global" for YAML compat for h in container.get("agents", {}).get("heartbeat", []): h["global"] = h.pop("is_global", False) @@ -571,6 +595,13 @@ def _preprocess(data: dict[str, Any]) -> dict[str, Any]: # existing runtime/model/assignment system. _expand_bindings(agents_data) + remote_runtime = agents_data.get("remote_runtime") + if isinstance(remote_runtime, dict): + remote_runtime = dict(remote_runtime) + if "class" in remote_runtime: + remote_runtime["class_path"] = remote_runtime.pop("class") + agents_data["remote_runtime"] = remote_runtime + heartbeat_raw = agents_data.pop("heartbeat", None) old_reflect = agents_data.pop("reflect_every", None) old_heartbeat = agents_data.pop("heartbeat_every", None) diff --git a/coral/web/api.py b/coral/web/api.py index 7ffbb1de..8c4cb0c2 100644 --- a/coral/web/api.py +++ b/coral/web/api.py @@ -11,6 +11,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse +from coral.agent.remote import read_remote_state from coral.cli._helpers import is_docker_run_alive @@ -586,5 +587,6 @@ async def get_status(request: Request) -> JSONResponse: "best_score": best.score if best else None, "best_title": best.title if best else None, "agents": agents_status, + "remote_state": read_remote_state(coral_dir), } ) diff --git a/tests/test_config.py b/tests/test_config.py index 90d9a94f..4cd6382c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -93,6 +93,26 @@ def test_agent_runtime_options_roundtrip(): } +def test_remote_runtime_config_accepts_class_key(): + config = CoralConfig.from_dict( + { + "task": {"name": "t", "description": "d"}, + "agents": { + "remote_runtime": { + "class": "pkg.module:Runtime", + "config": {"region": "us-west-2"}, + "sync_interval_seconds": 10, + }, + }, + } + ) + + assert config.agents.remote_runtime.class_path == "pkg.module:Runtime" + assert config.agents.remote_runtime.config == {"region": "us-west-2"} + assert config.agents.remote_runtime.sync_interval_seconds == 10 + assert config.to_dict()["agents"]["remote_runtime"]["class"] == "pkg.module:Runtime" + + def test_config_setup_roundtrip(): config = CoralConfig( task=TaskConfig(name="test", description="A test"), diff --git a/tests/test_remote_runtime.py b/tests/test_remote_runtime.py new file mode 100644 index 00000000..ff0729d1 --- /dev/null +++ b/tests/test_remote_runtime.py @@ -0,0 +1,113 @@ +"""Remote runtime adapter contracts and state bridge.""" + +from __future__ import annotations + +import json +import sys +import types +from pathlib import Path +from typing import Any + +import pytest + +from coral.agent.remote import ( + RemoteAgentHandle, + RemoteAgentSpec, + RemoteAgentState, + RemoteStateBridge, + load_remote_runtime, + read_remote_state, +) + + +class _FakeRemoteRuntime: + runtime_type = "fake" + + def __init__(self, prefix: str = "agent") -> None: + self.prefix = prefix + + @classmethod + def from_config(cls, config: dict[str, Any]) -> _FakeRemoteRuntime: + return cls(prefix=str(config.get("prefix", "agent"))) + + def deploy(self, agent_spec: RemoteAgentSpec) -> RemoteAgentHandle: + return RemoteAgentHandle( + agent_id=agent_spec.agent_id, + runtime_type=self.runtime_type, + runtime_id=f"remote-{agent_spec.agent_id}", + ) + + def invoke(self, handle: RemoteAgentHandle, payload: dict[str, Any]) -> dict[str, Any]: + return {"runtime_id": handle.runtime_id, "payload": payload} + + def list_agents(self) -> list[RemoteAgentHandle]: + return [ + RemoteAgentHandle( + agent_id=f"{self.prefix}-1", + runtime_type=self.runtime_type, + runtime_id="remote-1", + status="running", + ) + ] + + def collect_metrics(self) -> list[RemoteAgentState]: + return [ + RemoteAgentState( + agent_id=f"{self.prefix}-1", + runtime_type=self.runtime_type, + runtime_id="remote-1", + status="running", + metrics={"score": 0.42}, + artifacts={"trace": "trace-1"}, + ) + ] + + +class _NotRemoteRuntime: + runtime_type = "fake" + + +@pytest.fixture +def fake_remote_module() -> types.ModuleType: + mod_name = "coral_test_fake_remote_runtime" + module = types.ModuleType(mod_name) + module.FakeRemoteRuntime = _FakeRemoteRuntime # type: ignore[attr-defined] + module.NotRemoteRuntime = _NotRemoteRuntime # type: ignore[attr-defined] + sys.modules[mod_name] = module + yield module + sys.modules.pop(mod_name, None) + + +def test_load_remote_runtime_uses_from_config(fake_remote_module: types.ModuleType) -> None: + runtime = load_remote_runtime( + "coral_test_fake_remote_runtime:FakeRemoteRuntime", + {"prefix": "remote-agent"}, + ) + + states = runtime.collect_metrics() + assert states[0].agent_id == "remote-agent-1" + + +def test_load_remote_runtime_rejects_non_protocol( + fake_remote_module: types.ModuleType, +) -> None: + with pytest.raises(TypeError, match="RemoteRuntime protocol"): + load_remote_runtime("coral_test_fake_remote_runtime:NotRemoteRuntime") + + +def test_remote_state_bridge_writes_index_and_agent_file(tmp_path: Path) -> None: + runtime = _FakeRemoteRuntime(prefix="strategy") + state_dir = tmp_path / ".coral" / "public" / "remote_state" + bridge = RemoteStateBridge(runtime, state_dir) + + states = bridge.sync_once() + + assert len(states) == 1 + index = read_remote_state(tmp_path / ".coral") + assert index is not None + assert index["runtime_type"] == "fake" + assert index["agents"][0]["agent_id"] == "strategy-1" + assert index["agents"][0]["metrics"] == {"score": 0.42} + + agent_payload = json.loads((state_dir / "strategy-1.json").read_text()) + assert agent_payload["artifacts"] == {"trace": "trace-1"} From b7013f98b50582588d02552faa6950053bfb33b4 Mon Sep 17 00:00:00 2001 From: Hector Valverde Date: Mon, 29 Jun 2026 16:31:12 +0200 Subject: [PATCH 2/3] docs: document remote runtime state bridge --- README.md | 2 +- coral/agent/remote.py | 4 +++- .../docs/getting-started/configuration.mdx | 18 ++++++++++++++ docs/content/docs/guides/agent-runtimes.mdx | 24 +++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d64ec07a..f5a6e11b 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Skills: `coral-quickstart` (install → setup → `.coral_workspace/`), `setting | [Kiro](https://kiro.dev) | `kiro` | | [OpenCode](https://github.com/opencode-ai/opencode) | `opencode` | -Each agent must be installed and authenticated separately. Per-runtime config — including the [LiteLLM gateway](https://docs.coralxyz.com/guides/gateway) for custom models — is documented at [Agent Runtimes](https://docs.coralxyz.com/guides/agent-runtimes). +Each agent must be installed and authenticated separately. Per-runtime config — including the [LiteLLM gateway](https://docs.coralxyz.com/guides/gateway) for custom models and the opt-in remote runtime state bridge — is documented at [Agent Runtimes](https://docs.coralxyz.com/guides/agent-runtimes). ### How It Works diff --git a/coral/agent/remote.py b/coral/agent/remote.py index 747a69ce..55e827fe 100644 --- a/coral/agent/remote.py +++ b/coral/agent/remote.py @@ -98,7 +98,9 @@ def load_remote_runtime(class_path: str, config: dict[str, Any] | None = None) - runtime_class = getattr(module, class_name) runtime_config = config or {} from_config = getattr(runtime_class, "from_config", None) - runtime = from_config(runtime_config) if callable(from_config) else runtime_class(**runtime_config) + runtime = ( + from_config(runtime_config) if callable(from_config) else runtime_class(**runtime_config) + ) if not isinstance(runtime, RemoteRuntime): raise TypeError( f"{class_path} does not satisfy the RemoteRuntime protocol " diff --git a/docs/content/docs/getting-started/configuration.mdx b/docs/content/docs/getting-started/configuration.mdx index 01dbc9aa..95a07bcd 100644 --- a/docs/content/docs/getting-started/configuration.mdx +++ b/docs/content/docs/getting-started/configuration.mdx @@ -153,6 +153,7 @@ packaged-grader layout. | `timeout` | int | `3600` | Agent session timeout in seconds | | `research` | bool | `true` | Enable web search in agent workflow | | `heartbeat` | list | see below | Periodic actions triggered during eval loop | +| `remote_runtime` | object | disabled | Optional `class` + `config` adapter that syncs remote agent state into `.coral/public/remote_state/` for `coral status` and the web UI | ### Heartbeat actions @@ -173,6 +174,23 @@ You can also define custom heartbeat actions with a prompt: coral heartbeat set review --every 5 --prompt "Review your approach and consider alternatives." ``` +### Remote runtime state + +`agents.remote_runtime` is an optional state bridge for agents that run +outside CORAL-managed local worktrees. It does not replace `agents.runtime` or +spawn remote agents by itself; it loads an adapter that implements +`coral.agent.remote.RemoteRuntime`, periodically calls `collect_metrics()`, and +writes normalized snapshots to `.coral/public/remote_state/`. + +```yaml +agents: + remote_runtime: + class: my_package.agentcore:AgentCoreRuntime + config: + region: us-west-2 + sync_interval_seconds: 30 +``` + ## `islands` section Multi-island mode partitions a run into isolated agent groups. Agents on the diff --git a/docs/content/docs/guides/agent-runtimes.mdx b/docs/content/docs/guides/agent-runtimes.mdx index cdc4014d..1234aa0a 100644 --- a/docs/content/docs/guides/agent-runtimes.mdx +++ b/docs/content/docs/guides/agent-runtimes.mdx @@ -14,6 +14,30 @@ CORAL works with any coding agent that can run as a subprocess. Pick the runtime | Cursor Agent | `cursor` (alias: `cursor_agent`) | `cursor-agent login` once | | Kiro | `kiro` | `kiro-cli` setup | +## Remote Runtime State Bridge + +Use `agents.remote_runtime` when an agent executes outside the local CORAL +worktree but you still want `coral status` and the web dashboard to show its +state. The adapter is additive: local subprocess agents keep using +`agents.runtime`, and the remote adapter only syncs state into +`.coral/public/remote_state/`. + +```yaml +agents: + remote_runtime: + class: my_package.remote:Runtime + config: + endpoint: https://example.internal + sync_interval_seconds: 30 +``` + +The class must implement `coral.agent.remote.RemoteRuntime`: + +- `deploy(agent_spec) -> RemoteAgentHandle` +- `invoke(handle, payload) -> dict` +- `list_agents() -> list[RemoteAgentHandle]` +- `collect_metrics() -> list[RemoteAgentState]` + ## OpenCode [OpenCode](https://github.com/opencode-ai/opencode) reads permissions and provider config from an `opencode.json` file. Place it in your task's `seed/` directory so it gets copied into each agent's worktree. From 54d5cc39241c68a2d9890d048c8d59f836cbe55a Mon Sep 17 00:00:00 2001 From: Hector Valverde Date: Tue, 30 Jun 2026 09:14:56 +0200 Subject: [PATCH 3/3] fix: encode remote state filenames --- coral/agent/remote.py | 8 +++++++- tests/test_remote_runtime.py | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/coral/agent/remote.py b/coral/agent/remote.py index 55e827fe..a184baa8 100644 --- a/coral/agent/remote.py +++ b/coral/agent/remote.py @@ -9,6 +9,7 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any, Protocol, runtime_checkable +from urllib.parse import quote def utc_now_iso() -> str: @@ -123,7 +124,7 @@ def sync_once(self) -> list[RemoteAgentState]: self.state_dir.mkdir(parents=True, exist_ok=True) for state in serialized: - self._write_json(self.state_dir / f"{state['agent_id']}.json", state) + self._write_json(self.state_dir / self._state_filename(state["agent_id"]), state) self._write_json( self.state_dir / "index.json", @@ -141,6 +142,11 @@ def _write_json(path: Path, payload: dict[str, Any]) -> None: tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") os.replace(tmp_path, path) + @staticmethod + def _state_filename(agent_id: Any) -> str: + encoded = quote(str(agent_id), safe="") + return f"{encoded or '_'}.json" + def read_remote_state(coral_dir: str | Path) -> dict[str, Any] | None: """Best-effort read of the remote state index for CLI and web status.""" diff --git a/tests/test_remote_runtime.py b/tests/test_remote_runtime.py index ff0729d1..0c1d08de 100644 --- a/tests/test_remote_runtime.py +++ b/tests/test_remote_runtime.py @@ -67,6 +67,18 @@ class _NotRemoteRuntime: runtime_type = "fake" +class _UnsafeIdRemoteRuntime(_FakeRemoteRuntime): + def collect_metrics(self) -> list[RemoteAgentState]: + return [ + RemoteAgentState( + agent_id="../remote/agent", + runtime_type=self.runtime_type, + runtime_id="remote-unsafe", + status="running", + ) + ] + + @pytest.fixture def fake_remote_module() -> types.ModuleType: mod_name = "coral_test_fake_remote_runtime" @@ -111,3 +123,16 @@ def test_remote_state_bridge_writes_index_and_agent_file(tmp_path: Path) -> None agent_payload = json.loads((state_dir / "strategy-1.json").read_text()) assert agent_payload["artifacts"] == {"trace": "trace-1"} + + +def test_remote_state_bridge_encodes_agent_id_for_filename(tmp_path: Path) -> None: + state_dir = tmp_path / ".coral" / "public" / "remote_state" + bridge = RemoteStateBridge(_UnsafeIdRemoteRuntime(), state_dir) + + bridge.sync_once() + + assert (state_dir / "..%2Fremote%2Fagent.json").exists() + assert not (tmp_path / ".coral" / "public" / "remote").exists() + index = read_remote_state(tmp_path / ".coral") + assert index is not None + assert index["agents"][0]["agent_id"] == "../remote/agent"