PR-2 of the multi-workspace external-agent stack. PR-1 (#2739) landed per-workspace auth + heartbeat + inbox. This PR threads ``source_workspace_id`` through the A2A client + tool surface so an agent registered against multiple workspaces can list peers across all of them and delegate from a specific source. Changes ------- * ``a2a_client``: ``discover_peer``, ``send_a2a_message``, ``get_peers_with_diagnostic``, and ``enrich_peer_metadata`` now accept ``source_workspace_id``. Routing uses it for both the X-Workspace-ID header and (transitively, via ``auth_headers(src)``) the bearer token. Defaults to module-level WORKSPACE_ID for back-compat. * ``a2a_client._peer_to_source``: a new lock-free cache mapping each discovered peer back to the source workspace whose registry surfaced it. ``tool_list_peers`` populates the cache on every call; ``tool_delegate_task`` consults it for auto-routing. * ``a2a_tools.tool_list_peers(source_workspace_id=None)``: when multiple workspaces are registered (MOLECULE_WORKSPACES) and no explicit source is passed, aggregates peers across every registered workspace and tags each entry with ``via: <src[:8]>``. Single-workspace mode is unchanged — no ``via:`` annotation, same output shape. * ``a2a_tools.tool_delegate_task`` and ``tool_delegate_task_async`` resolve source via ``source_workspace_id arg → _peer_to_source[target] → WORKSPACE_ID``. Agents almost never need to specify ``source_*`` explicitly — call ``list_peers`` first and the cache handles the rest. * ``tool_delegate_task_async`` idempotency key now includes the source workspace, so the same task delegated from two registered workspaces produces two distinct delegations (the right behavior — one per tenant audit trail). * ``platform_auth.list_registered_workspaces()``: new helper for the tool layer to enumerate the multi-ws registry. Lock-free reads matched by the existing single-writer-per-workspace contract from PR-1. * ``platform_auth.self_source_headers``: now passes ``workspace_id`` through to ``auth_headers`` — without this, a multi-workspace POST source-tagged with ``X-Workspace-ID=ws_b`` was authenticating with ws_a's token (or no token if MOLECULE_WORKSPACE_TOKEN unset). Latent PR-1 bug exposed by the new tool surface. * ``a2a_mcp_server`` tool dispatch passes ``source_workspace_id`` from the tool call arguments. * ``platform_tools.registry``: add ``source_workspace_id`` to the delegate_task, delegate_task_async, check_task_status, list_peers input schemas with copy explaining when to use it (rarely — the cache handles it). Tests (15 new, all passing) --------------------------- ``test_a2a_multi_workspace.py``: * TestDiscoverPeerSourceRouting (3): src arg drives header+token, fallback to module ws when omitted, invalid target short-circuits before any HTTP attempt. * TestSendA2AMessageSourceRouting (1): X-Workspace-ID source header + Authorization bearer both come from the source arg via the patched self_source_headers chain. * TestGetPeersSourceRouting (1): URL path AND headers use the source workspace id. * TestToolListPeersAggregation (4): aggregates across multiple registered workspaces, tags origin, leaves single-workspace path unchanged, explicit src arg overrides aggregation, diagnostic joining when every workspace returns empty. * TestToolDelegateTaskAutoRouting (3): cache-driven auto-route, explicit override beats cache, single-workspace fallback to module WORKSPACE_ID. * TestListRegisteredWorkspaces (3): registry enumeration helper. Plus ``tests/snapshots/a2a_instructions_mcp.txt`` regenerated to absorb the new ``source_workspace_id`` schema entries. Back-compat ----------- Every change defaults ``source_workspace_id=None``; legacy single-workspace operators (no MOLECULE_WORKSPACES) see identical behavior — same URLs, same headers, same tool output. The 24 PR-1 tests + 125 existing A2A tests all still pass. Out of scope (PR-3) ------------------- Memory namespacing per registered workspace lands after the new memory system v2 PR (#2740) settles in production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| __init__.py | ||
| README.md | ||
| registry.py | ||
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:
- MCP server (
workspace/a2a_mcp_server.py) — theTOOLSJSON list - LangChain
@toolwrappers (workspace/builtin_tools/{delegation,memory}.py) - 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
TOOLSlist from_PLATFORM_TOOL_SPECSat import time - LangChain
@toolwrappers readname=spec.namefrom the registry - Doc generator (
executor_helpers._render_section()) produces the system-prompt block fromspec.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
- Append a
ToolSpec(...)toTOOLSinregistry.py. - Add the LangChain
@toolwrapper inworkspace/builtin_tools/(the wrapper body just callsspec.impl). - Update
_CLI_A2A_COMMAND_KEYWORDSinexecutor_helpers.py— set the value to the CLI subcommand keyword, or toNoneif the tool isn't exposed via the subprocess interface. - Regenerate snapshots — see the comment block at the top of
workspace/tests/test_platform_tools.pyfor the one-liner. - Run
pytest workspace/tests/test_platform_tools.py --no-cov.
Renaming a tool
Edit name in registry.py only. Then:
- The MCP TOOLS list rebuilds automatically.
- The doc generator regenerates automatically (snapshots will fail the diff — regenerate them).
- Search
workspace/for the old literal in case a non-adapter consumer (tests, plugin code) hardcoded the old name; update those. - Update any
_CLI_A2A_COMMAND_KEYWORDSkey + the literal substring in_A2A_INSTRUCTIONS_CLIif 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.