molecule-core/workspace/platform_tools
Hongming Wang b47d4ceb00 feat(workspace-runtime): add inbox polling for standalone molecule-mcp path
The universal MCP server (a2a_mcp_server.py) was outbound-only — agents
in standalone runtimes (Claude Code, hermes, codex, etc.) could
delegate, list peers, and write memories, but never observed the
canvas-user or peer-agent messages addressed to them. This blocked
"constantly responding" loops without forcing operators back onto a
runtime-specific channel plugin.

This PR closes the inbound gap with a poller-fed in-memory queue and
three new MCP tools:

  - wait_for_message(timeout_secs?) — block until next message arrives
  - inbox_peek(limit?)              — list pending messages (non-destructive)
  - inbox_pop(activity_id)          — drop a handled message

A daemon thread polls /workspaces/:id/activity?type=a2a_receive every
5s, fills the queue from the cursor (since_id), and persists the cursor
to ${CONFIGS_DIR}/.mcp_inbox_cursor so a restart doesn't replay backlog.
On 410 (cursor pruned) we fall back to since_secs=600 for a bounded
recovery window. Activity-row → InboxMessage extraction mirrors the
molecule-mcp-claude-channel plugin's extractText (envelope shapes #1-3
+ summary fallback).

mcp_cli.main starts the poller alongside the existing register +
heartbeat threads. In-container runtimes (which have push delivery via
canvas WebSocket) skip activation, so inbox tools return an
informational "(inbox not enabled)" message instead of double-delivery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:32:48 -07:00
..
__init__.py feat(platform): single-source-of-truth tool registry — adapters consume, no drift 2026-04-28 17:11:36 -07:00
README.md docs: registry pattern + harness scripts READMEs 2026-04-28 22:19:40 -07:00
registry.py feat(workspace-runtime): add inbox polling for standalone molecule-mcp path 2026-04-30 16:32:48 -07:00

Platform tool registry

Single source of truth for every tool the platform exposes to agents (A2A delegation, hierarchical memory, broadcast, introspection).

Why this exists

Pre-#2240, three places independently declared each tool:

  1. MCP server (workspace/a2a_mcp_server.py) — the TOOLS JSON list
  2. LangChain @tool wrappers (workspace/builtin_tools/{delegation,memory}.py)
  3. Agent-facing system-prompt docs (workspace/executor_helpers.py)

Adding a tool to one and forgetting the others happened repeatedly. The canonical case: send_message_to_user was registered in MCP TOOLS but the executor_helpers doc string never mentioned it, so agents saw the tool as available but had no usage guidance — a silent capability regression.

What the registry does

registry.py defines each tool ONCE as a frozen ToolSpec:

ToolSpec(
    name="delegate_task",
    short="Delegate a task to a peer workspace via A2A and WAIT for the response.",
    when_to_use="Use for QUICK questions and small sub-tasks where you can afford to wait inline...",
    input_schema={...},          # JSON Schema, consumed by MCP server
    impl=tool_delegate_task,     # the actual coroutine
    section="a2a",               # which prompt section it belongs to
)

Adapters consume specs; no hardcoded names anywhere else:

  • MCP server builds its TOOLS list from _PLATFORM_TOOL_SPECS at import time
  • LangChain @tool wrappers read name=spec.name from the registry
  • Doc generator (executor_helpers._render_section()) produces the system-prompt block from spec.short (bullet) + spec.when_to_use (heading + paragraph)

CLI subprocess block — special case

Non-MCP runtimes (ollama, custom subprocess adapters) use a separate hand-maintained block in executor_helpers._A2A_INSTRUCTIONS_CLI because the CLI subcommand vocabulary (peers, delegate, status, info) differs from the MCP tool names (list_peers, delegate_task, etc.). Auto-generation would lose the readable invocation syntax.

Alignment is enforced via _CLI_A2A_COMMAND_KEYWORDS (in executor_helpers.py): every a2a-section spec must be keyed there with either a CLI subcommand keyword OR an explicit None if the tool is intentionally not exposed via subprocess (e.g. send_message_to_user because its structured attachments field doesn't survive positional-arg shell invocation).

Tests that catch drift

workspace/tests/test_platform_tools.py:

Test What it catches
test_mcp_server_registers_every_registry_tool MCP TOOLS list out of sync with registry
test_mcp_tool_descriptions_match_registry_short hand-edited MCP description that drifted
test_mcp_tool_input_schemas_match_registry schema duplicated in server file
test_a2a_instructions_text_includes_every_a2a_tool doc generator missed a tool
test_old_pre_rename_names_not_present_in_docs stale name leaked back in
test_a2a_mcp_instructions_match_snapshot rendered shape (bullet ordering, headings, footers) drifted
test_a2a_cli_instructions_match_snapshot CLI block edited in a way that changes shape
test_hma_instructions_match_snapshot HMA section drifted
test_cli_keyword_mapping_covers_every_a2a_tool tool added to registry without a CLI mapping decision
test_cli_keyword_substrings_appear_in_cli_block CLI keyword in the mapping but missing from the doc block

The snapshot files at workspace/tests/snapshots/*.txt are LF-pinned in .gitattributes so a Windows contributor with core.autocrlf=true doesn't get mysterious test failures.

Adding a new tool

  1. Append a ToolSpec(...) to TOOLS in registry.py.
  2. Add the LangChain @tool wrapper in workspace/builtin_tools/ (the wrapper body just calls spec.impl).
  3. Update _CLI_A2A_COMMAND_KEYWORDS in executor_helpers.py — set the value to the CLI subcommand keyword, or to None if the tool isn't exposed via the subprocess interface.
  4. Regenerate snapshots — see the comment block at the top of workspace/tests/test_platform_tools.py for the one-liner.
  5. Run pytest workspace/tests/test_platform_tools.py --no-cov.

Renaming a tool

Edit name in registry.py only. Then:

  1. The MCP TOOLS list rebuilds automatically.
  2. The doc generator regenerates automatically (snapshots will fail the diff — regenerate them).
  3. Search workspace/ for the old literal in case a non-adapter consumer (tests, plugin code) hardcoded the old name; update those.
  4. Update any _CLI_A2A_COMMAND_KEYWORDS key + the literal substring in _A2A_INSTRUCTIONS_CLI if applicable.

Removing a tool

Delete the ToolSpec and the _CLI_A2A_COMMAND_KEYWORDS key. Adapters and doc generators stop registering it automatically; the structural tests prevent stale references from surviving.