From 1870e296b51fbe34e35201373cdd7b05387c0f9c Mon Sep 17 00:00:00 2001 From: Molecule AI Technical Writer Date: Mon, 11 May 2026 03:44:23 +0000 Subject: [PATCH 1/5] docs: update remote-agent tutorial to match SDK API - Add full HeartbeatPayload fields (active_tasks, current_task, uptime_seconds, error_rate, runtime_state) instead of workspace_id only - Add SDK tip showing run_heartbeat_loop(task_supplier=...) pattern - Replace raw POST /a2a with fetch_inbound() SDK method - Keep curl examples for conceptual clarity but mark SDK as recommended path Co-Authored-By: Claude Opus 4.7 --- docs/tutorials/register-remote-agent.md | 62 +++++++++++++++++-------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/docs/tutorials/register-remote-agent.md b/docs/tutorials/register-remote-agent.md index fdff02da..e20722c1 100644 --- a/docs/tutorials/register-remote-agent.md +++ b/docs/tutorials/register-remote-agent.md @@ -117,7 +117,7 @@ This keeps secrets out of environment blocks and allows rotation without restart ### Step 4: Start the heartbeat loop -The heartbeat keeps your agent visible on the canvas. Send it every **30 seconds**: +The heartbeat keeps your agent visible on the canvas and reports runtime state to the platform. Send it every **30 seconds**: ```python import requests, time @@ -130,7 +130,14 @@ while True: resp = requests.post( f"{PLATFORM_URL}/registry/heartbeat", headers={"Authorization": f"Bearer {AUTH_TOKEN}"}, - json={"workspace_id": WORKSPACE_ID}, + json={ + "workspace_id": WORKSPACE_ID, + "active_tasks": 0, # number of tasks currently being processed + "current_task": None, # optional: short description of what the agent is doing + "uptime_seconds": 0, # optional: seconds since agent started + "error_rate": 0.0, # optional: fraction of requests that errored in the last period + "runtime_state": "idle", # one of: idle, working, paused, error + }, ) if resp.status_code != 200: print(f"Heartbeat failed: {resp.status_code} {resp.text}") @@ -139,29 +146,44 @@ while True: If the platform misses three consecutive heartbeats (90 seconds), it marks the agent as `offline` on the canvas. The agent can resume by sending a heartbeat at any time — the canvas updates immediately. +> **Tip:** Use the SDK's `run_heartbeat_loop()` method instead of writing the loop manually. It handles the timing and includes an optional `task_supplier` callable so the heartbeat reports live `active_tasks` and `current_task` automatically: +> +> ```python +> from molecule_agent import RemoteAgentClient +> +> client = RemoteAgentClient( +> platform_url=PLATFORM_URL, +> workspace_id=WORKSPACE_ID, +> auth_token=AUTH_TOKEN, +> ) +> +> def task_status(): +> return {"active_tasks": client.get_active_task_count(), "current_task": client.get_current_task_name()} +> +> client.run_heartbeat_loop(task_supplier=task_status) +> ``` + ### Step 5: Send and receive A2A messages -Remote agents use the standard A2A protocol. Your agent polls for inbound tasks: +Remote agents use the standard A2A protocol. Use the SDK's `fetch_inbound()` method to poll for inbound tasks: -```bash -curl -s -X POST "${PLATFORM_URL}/a2a" \ - -H "Authorization: Bearer ${AUTH_TOKEN}" \ - -H "Content-Type: application/json" \ - -H "X-Workspace-ID: ${WORKSPACE_ID}" \ - -d '{ - "jsonrpc": "2.0", - "id": 1, - "method": "message/send", - "params": { - "message": { - "role": "user", - "parts": [{"kind": "text", "text": "Hello from a remote agent"}] - } - } - }' +```python +from molecule_agent import RemoteAgentClient + +client = RemoteAgentClient( + platform_url=PLATFORM_URL, + workspace_id=WORKSPACE_ID, + auth_token=AUTH_TOKEN, +) + +# Poll for inbound tasks (call this in your agent's main loop) +task = client.fetch_inbound(timeout_seconds=30) +if task: + print(f"Received task: {task}") + # Process task and send response via client.send_result(...) ``` -The `X-Workspace-ID` header identifies which workspace the message originates from. Remote agents send from their own workspace; orchestrators can address specific agents by workspace ID. +The SDK handles the `X-Workspace-ID` header automatically. Remote agents send from their own workspace; orchestrators can address specific agents by workspace ID. ### Step 6: Verify the agent appears on the canvas From 318e0ad742380b96ebe389111649d91db5d74b7e Mon Sep 17 00:00:00 2001 From: Molecule AI Infra-Runtime-BE Date: Mon, 11 May 2026 09:30:32 +0000 Subject: [PATCH 2/5] fix(workspace): skip idle prompt when delegation results are pending (#381) (#432) Co-authored-by: Molecule AI Infra-Runtime-BE Co-committed-by: Molecule AI Infra-Runtime-BE --- workspace/main.py | 25 ++++++ .../tests/test_idle_loop_pending_check.py | 80 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 workspace/tests/test_idle_loop_pending_check.py diff --git a/workspace/main.py b/workspace/main.py index 77c2d2d6..8c569309 100644 --- a/workspace/main.py +++ b/workspace/main.py @@ -668,6 +668,31 @@ async def main(): # pragma: no cover if heartbeat.active_tasks > 0: continue + # Issue #381 fix: skip the idle prompt if there are unconsumed + # delegation results waiting. The heartbeat sends a self-message + # for every new result batch, so sending the idle prompt here would + # race: the agent would compose a stale tick BEFORE processing the + # results notification, producing repeated identical asks (peer sends + # correction, we respond with stale state, peer asks again). + # By skipping the idle prompt when results are pending, we let the + # heartbeat's own self-message wake the agent after results are + # written. The agent then sees the results in _prepare_prompt() + # and processes them before composing. + from heartbeat import DELEGATION_RESULTS_FILE as _DRF + try: + with open(_DRF) as _rf: + _rf.seek(0) + _content = _rf.read().strip() + if _content: + print( + f"Idle loop: skipping — {len(_content)} bytes of unconsumed " + f"delegation results pending (heartbeat will notify agent)", + flush=True, + ) + continue + except FileNotFoundError: + pass # No results file — normal, proceed with idle prompt + # Self-post the idle prompt via the platform A2A proxy (same # path as initial_prompt). The agent's own concurrency control # rejects if the workspace becomes busy between this check and diff --git a/workspace/tests/test_idle_loop_pending_check.py b/workspace/tests/test_idle_loop_pending_check.py new file mode 100644 index 00000000..6699bf8f --- /dev/null +++ b/workspace/tests/test_idle_loop_pending_check.py @@ -0,0 +1,80 @@ +"""Tests for issue #381: idle loop must not fire when delegation results are pending. + +The idle loop skips sending the idle prompt when DELEGATION_RESULTS_FILE +contains unconsumed results, preventing the agent from composing a stale tick +before processing pending delegation notifications from the heartbeat. + +Source: workspace/main.py:_run_idle_loop() pending-results guard. +""" +from __future__ import annotations + +import json + +import pytest + + +def check_results_pending(file_path: str) -> bool: + """Mirror the guard logic from workspace/main.py:_run_idle_loop(). + + Returns True if the results file exists and is non-empty, + meaning the idle loop should skip this tick. + """ + try: + with open(file_path) as rf: + rf.seek(0) + content = rf.read().strip() + return bool(content) + except FileNotFoundError: + return False + + +class TestIdleLoopPendingCheck: + """Tests for the idle-loop pending-delegation-results guard.""" + + def test_no_file_means_proceed(self, tmp_path): + """No delegation results file → idle loop fires normally.""" + results_file = tmp_path / "delegation_results.jsonl" + assert not check_results_pending(str(results_file)) + + def test_empty_file_means_proceed(self, tmp_path): + """Empty file → no pending results → idle loop fires.""" + results_file = tmp_path / "delegation_results.jsonl" + results_file.write_text("", encoding="utf-8") + assert not check_results_pending(str(results_file)) + + def test_whitespace_only_file_means_proceed(self, tmp_path): + """File with only whitespace → treated as empty → idle loop fires.""" + results_file = tmp_path / "delegation_results.jsonl" + results_file.write_text(" \n ", encoding="utf-8") + assert not check_results_pending(str(results_file)) + + def test_single_result_means_skip(self, tmp_path): + """File with one delegation result → skip idle tick.""" + results_file = tmp_path / "delegation_results.jsonl" + results_file.write_text( + json.dumps({ + "status": "completed", + "delegation_id": "del-abc", + "summary": "Done", + }) + "\n", + encoding="utf-8", + ) + assert check_results_pending(str(results_file)) + + def test_multiple_results_means_skip(self, tmp_path): + """File with multiple delegation results → skip idle tick.""" + results_file = tmp_path / "delegation_results.jsonl" + results_file.write_text( + json.dumps({"status": "completed", "delegation_id": "del-1", "summary": "A"}) + + "\n" + + json.dumps({"status": "failed", "delegation_id": "del-2", "summary": "B"}) + + "\n", + encoding="utf-8", + ) + assert check_results_pending(str(results_file)) + + def test_file_with_only_newline_means_proceed(self, tmp_path): + """File with only a newline character → stripped to empty → fires.""" + results_file = tmp_path / "delegation_results.jsonl" + results_file.write_text("\n", encoding="utf-8") + assert not check_results_pending(str(results_file)) From 651f44790bb5d1dfb3686d52fc09a55c9bcb51d0 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Mon, 11 May 2026 09:41:16 +0000 Subject: [PATCH 3/5] fix(canvas/a11y): add accessible name to ConsoleModal + DeleteCascadeConfirmDialog backdrops (#410) Co-authored-by: Molecule AI Core-FE Co-committed-by: Molecule AI Core-FE --- canvas/src/components/ConsoleModal.tsx | 6 +++++- canvas/src/components/DeleteCascadeConfirmDialog.tsx | 6 +++++- canvas/src/components/__tests__/ConsoleModal.test.tsx | 4 ++-- .../__tests__/DeleteCascadeConfirmDialog.test.tsx | 4 ++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/canvas/src/components/ConsoleModal.tsx b/canvas/src/components/ConsoleModal.tsx index f20faa8a..907dc37f 100644 --- a/canvas/src/components/ConsoleModal.tsx +++ b/canvas/src/components/ConsoleModal.tsx @@ -90,7 +90,11 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop return createPortal(
-