forked from molecule-ai/molecule-core
Continues the OSS-shape refactor. After iters 4a-4d (rbac, delegation,
memory, messaging) the only behavior left in ``a2a_tools.py`` was
``report_activity`` plus three thin inbox-tool wrappers and the
``_enrich_inbound_for_agent`` helper. This iter extracts the inbox
slice to ``a2a_tools_inbox.py`` so the kitchen-sink module shrinks
from 280 LOC to ~165 LOC of imports + report_activity + back-compat
re-export blocks.
Extracted symbols:
- ``_INBOX_NOT_ENABLED_MSG`` (sentinel)
- ``_enrich_inbound_for_agent`` (poll-path peer enrichment helper)
- ``tool_inbox_peek``
- ``tool_inbox_pop``
- ``tool_wait_for_message``
Re-exports (`from a2a_tools_inbox import …`) preserve the public
``a2a_tools.tool_inbox_*`` surface so existing tests + call sites
continue to resolve unchanged.
New tests in test_a2a_tools_inbox_split.py:
1. **Drift gate (5)** — every previously-public symbol on a2a_tools
is the EXACT same object as a2a_tools_inbox.foo (`is`, not `==`),
catches a future "wrap with logging" refactor that silently loses
existing test coverage.
2. **Import contract (1)** — a2a_tools_inbox does NOT eagerly import
a2a_tools at module load. Pins the layered architecture: the
extracted slice depends on ``inbox`` + a lazy ``a2a_client``
import, never on the kitchen-sink that re-exports it.
3. **_enrich_inbound_for_agent branches (5)** — peer_id-empty
(canvas_user) returns dict unchanged; missing peer_id key same;
a2a_client unavailable (test harness, partial install) degrades
gracefully with a bare envelope; registry hit populates
peer_name + peer_role + agent_card_url; registry miss still
surfaces agent_card_url (constructable from peer_id alone).
The full timeout-clamp / validation / JSON-shape behavior matrix for
the three wrappers stays in test_a2a_tools_inbox_wrappers.py — those
tests pass identically against both the alias and the underlying impl.
Wiring updates:
- ``scripts/build_runtime_package.py``: add ``a2a_tools_inbox`` to
``TOP_LEVEL_MODULES`` so it ships in the runtime wheel and the
drift gate doesn't fail the next publish.
- ``.github/workflows/ci.yml``: add ``a2a_tools_inbox.py`` to
``CRITICAL_FILES`` so the 75% MCP/inbox/auth per-file floor
applies — this is now where the inbox-delivery code actually
lives.
141 lines
5.9 KiB
Python
141 lines
5.9 KiB
Python
"""Inbox tool handlers — single-concern slice of the a2a_tools surface.
|
|
|
|
Standalone-runtime path for inbound-message delivery (push-mode runtimes
|
|
get messages via the channel-tag synthesis in a2a_mcp_server). The
|
|
``InboxState`` singleton is set by ``mcp_cli`` before the MCP server
|
|
starts; in-container runtimes never call ``inbox.activate(...)`` so
|
|
``inbox.get_state()`` returns None and these tools surface an
|
|
informational error instead of raising.
|
|
|
|
When-to-use guidance for agents (mirrored in
|
|
``platform_tools/registry.py``):
|
|
- ``wait_for_message``: block until a new inbound message arrives, then
|
|
decide what to do with it; forms the loop ``wait → respond → wait``.
|
|
- ``inbox_peek``: inspect the queue non-destructively.
|
|
- ``inbox_pop``: remove a handled message by activity_id.
|
|
|
|
Extracted from ``a2a_tools.py`` in RFC #2873 iter 4e so the kitchen-sink
|
|
module shrinks to a back-compat shim. The extraction also makes the
|
|
``_enrich_inbound_for_agent`` helper unit-testable in isolation —
|
|
previously it was buried in ``a2a_tools`` and only exercised through
|
|
the inbox wrappers, leaving its peer-id-empty / cache-miss / registry-
|
|
unavailable branches under-covered.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
|
|
# Surfaced when the inbox subsystem is not initialised. Returned by the
|
|
# three inbox tool wrappers below so the agent gets a clear "this
|
|
# runtime delivers via push" message instead of a NameError.
|
|
_INBOX_NOT_ENABLED_MSG = (
|
|
"Error: inbox polling is not enabled in this runtime. The standalone "
|
|
"molecule-mcp wrapper activates it; in-container runtimes receive "
|
|
"messages via push delivery and do not need these tools."
|
|
)
|
|
|
|
|
|
def _enrich_inbound_for_agent(d: dict) -> dict:
|
|
"""Add peer_name / peer_role / agent_card_url to a poll-path message.
|
|
|
|
The PUSH path (a2a_mcp_server._build_channel_notification) already
|
|
enriches the meta dict with these fields, so a Claude Code host
|
|
with channel-push sees them. The POLL path goes through
|
|
InboxMessage.to_dict, which is intentionally identity-free (the
|
|
storage layer doesn't know about the registry cache). Without this
|
|
helper, every non-Claude-Code MCP client that uses inbox_peek /
|
|
wait_for_message gets a plain message and the receiving agent
|
|
can't tell who's writing — breaking the contract documented in
|
|
a2a_mcp_server.py:303-345 ("In both paths the same fields apply").
|
|
|
|
Cache-first non-blocking enrichment (same shape as push): on cache
|
|
miss the helper returns the bare message; the next call within the
|
|
5-min TTL hits the warm cache. Failure to enrich is non-fatal —
|
|
the agent still gets text + peer_id + kind + activity_id, just
|
|
without the friendly identity.
|
|
"""
|
|
peer_id = d.get("peer_id") or ""
|
|
if not peer_id:
|
|
# canvas_user — no peer to enrich; helper returns the plain
|
|
# message unchanged so the canvas reply path still works.
|
|
return d
|
|
try:
|
|
from a2a_client import ( # local import — avoid module-load cycle
|
|
_agent_card_url_for,
|
|
enrich_peer_metadata_nonblocking,
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
# If a2a_client is unavailable (test harness, partial install),
|
|
# degrade gracefully — agent still gets the bare envelope.
|
|
return d
|
|
record = enrich_peer_metadata_nonblocking(peer_id)
|
|
if record is not None:
|
|
if name := record.get("name"):
|
|
d["peer_name"] = name
|
|
if role := record.get("role"):
|
|
d["peer_role"] = role
|
|
# agent_card_url is constructable from peer_id alone — surface it
|
|
# even when registry enrichment misses, so the receiving agent has
|
|
# a single endpoint to hit for the peer's full capability list.
|
|
d["agent_card_url"] = _agent_card_url_for(peer_id)
|
|
return d
|
|
|
|
|
|
async def tool_inbox_peek(limit: int = 10) -> str:
|
|
"""Return up to ``limit`` pending inbound messages without removing them."""
|
|
import inbox # local import — avoids a circular dep at module load
|
|
|
|
state = inbox.get_state()
|
|
if state is None:
|
|
return _INBOX_NOT_ENABLED_MSG
|
|
messages = state.peek(limit=limit if isinstance(limit, int) else 10)
|
|
return json.dumps([_enrich_inbound_for_agent(m.to_dict()) for m in messages])
|
|
|
|
|
|
async def tool_inbox_pop(activity_id: str) -> str:
|
|
"""Remove a message from the inbox queue by activity_id."""
|
|
import inbox
|
|
|
|
state = inbox.get_state()
|
|
if state is None:
|
|
return _INBOX_NOT_ENABLED_MSG
|
|
if not isinstance(activity_id, str) or not activity_id:
|
|
return "Error: activity_id is required."
|
|
removed = state.pop(activity_id)
|
|
if removed is None:
|
|
return json.dumps({"removed": False, "activity_id": activity_id})
|
|
return json.dumps({"removed": True, "activity_id": activity_id})
|
|
|
|
|
|
async def tool_wait_for_message(timeout_secs: float = 60.0) -> str:
|
|
"""Block until a new message arrives or ``timeout_secs`` elapses.
|
|
|
|
Returns the head message non-destructively; the agent decides
|
|
whether to ``inbox_pop`` it after acting.
|
|
"""
|
|
import inbox
|
|
|
|
state = inbox.get_state()
|
|
if state is None:
|
|
return _INBOX_NOT_ENABLED_MSG
|
|
|
|
try:
|
|
timeout = float(timeout_secs)
|
|
except (TypeError, ValueError):
|
|
timeout = 60.0
|
|
# Cap at 300s — Claude Code's default tool timeout is ~10min, and
|
|
# blocking longer than 5min wastes the prompt cache window for
|
|
# nothing useful. Operators who want longer can call repeatedly.
|
|
timeout = max(0.0, min(timeout, 300.0))
|
|
|
|
# The threading.Event-based wait would block the asyncio loop.
|
|
# Run it on the default executor so the MCP server can keep
|
|
# processing other JSON-RPC requests while we sleep.
|
|
loop = asyncio.get_running_loop()
|
|
message = await loop.run_in_executor(None, state.wait, timeout)
|
|
if message is None:
|
|
return json.dumps({"timeout": True, "timeout_secs": timeout})
|
|
return json.dumps(_enrich_inbound_for_agent(message.to_dict()))
|