forked from molecule-ai/molecule-core
Merge remote-tracking branch 'origin/staging' into refactor/a2a-tools-messaging-extract-rfc2873-iter4d
# Conflicts: # workspace/a2a_tools.py
This commit is contained in:
commit
3322524b0f
@ -154,6 +154,73 @@ from a2a_tools_memory import ( # noqa: E402 (import after the top-of-module im
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbox tools — inbound delivery for the standalone molecule-mcp path.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The InboxState singleton is set by mcp_cli before the MCP server starts
|
||||
# (see workspace/inbox.py for the rationale). In-container runtimes never
|
||||
# call ``inbox.activate(...)``, so ``inbox.get_state()`` returns None and
|
||||
# these tools surface an informational error rather than raising.
|
||||
#
|
||||
# When-to-use guidance (mirrored in platform_tools/registry.py): agents
|
||||
# in standalone-runtime mode should call ``wait_for_message`` to block
|
||||
# on the next inbound message after they've emitted a reply, forming
|
||||
# the loop ``wait → respond → wait``. ``inbox_peek`` is for inspecting
|
||||
# the queue without consuming; ``inbox_pop`` removes a handled message.
|
||||
|
||||
_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
|
||||
@ -162,7 +229,7 @@ async def tool_inbox_peek(limit: int = 10) -> str:
|
||||
if state is None:
|
||||
return _INBOX_NOT_ENABLED_MSG
|
||||
messages = state.peek(limit=limit if isinstance(limit, int) else 10)
|
||||
return json.dumps([m.to_dict() for m in messages])
|
||||
return json.dumps([_enrich_inbound_for_agent(m.to_dict()) for m in messages])
|
||||
|
||||
|
||||
async def tool_inbox_pop(activity_id: str) -> str:
|
||||
@ -210,4 +277,4 @@ async def tool_wait_for_message(timeout_secs: float = 60.0) -> str:
|
||||
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(message.to_dict())
|
||||
return json.dumps(_enrich_inbound_for_agent(message.to_dict()))
|
||||
|
||||
150
workspace/tests/test_a2a_tools_inbox_enrichment.py
Normal file
150
workspace/tests/test_a2a_tools_inbox_enrichment.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""Tests for `_enrich_inbound_for_agent` — the poll-path companion to
|
||||
the push-path enrichment in `a2a_mcp_server._build_channel_notification`.
|
||||
|
||||
The MCP poll path (inbox_peek / wait_for_message) returns
|
||||
`InboxMessage.to_dict()`, which has `activity_id, text, peer_id, kind,
|
||||
method, created_at` but NOT the registry-resolved `peer_name`,
|
||||
`peer_role`, or `agent_card_url`. The receiving agent then sees a
|
||||
plain message and can't tell who's writing — breaking the universal
|
||||
contract documented in `a2a_mcp_server.py:303-345` ("In both paths
|
||||
the same fields apply").
|
||||
|
||||
The enrichment helper closes that gap. These tests pin:
|
||||
- canvas_user (peer_id="") passes through unchanged
|
||||
- peer_agent with cache hit gets peer_name + peer_role + agent_card_url
|
||||
- peer_agent with cache miss still gets agent_card_url (constructable
|
||||
from peer_id alone)
|
||||
- a2a_client unavailable (test harness without registry) degrades
|
||||
gracefully — agent still gets the bare envelope
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# a2a_client.py reads WORKSPACE_ID at import time and raises if it's
|
||||
# unset. Stamp a stub before any test pulls in a2a_tools (which transitively
|
||||
# imports a2a_client). conftest.py mocks the SDK but not this env var.
|
||||
os.environ.setdefault("WORKSPACE_ID", "00000000-0000-0000-0000-000000000001")
|
||||
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
PEER_UUID = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
|
||||
def test_canvas_user_passes_through_unchanged():
|
||||
from a2a_tools import _enrich_inbound_for_agent
|
||||
|
||||
base = {
|
||||
"activity_id": "act-1",
|
||||
"text": "hello from canvas",
|
||||
"peer_id": "",
|
||||
"kind": "canvas_user",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-05T11:00:00Z",
|
||||
}
|
||||
|
||||
out = _enrich_inbound_for_agent(dict(base))
|
||||
|
||||
# Plain pass-through — no enrichment fields added for canvas_user.
|
||||
assert out == base
|
||||
assert "peer_name" not in out
|
||||
assert "peer_role" not in out
|
||||
assert "agent_card_url" not in out
|
||||
|
||||
|
||||
def test_peer_agent_cache_hit_adds_name_role_and_card_url():
|
||||
from a2a_tools import _enrich_inbound_for_agent
|
||||
|
||||
record = {"name": "ops-agent", "role": "sre"}
|
||||
card_url = f"https://platform.example/registry/{PEER_UUID}/agent-card"
|
||||
|
||||
with patch(
|
||||
"a2a_client.enrich_peer_metadata_nonblocking",
|
||||
return_value=record,
|
||||
), patch(
|
||||
"a2a_client._agent_card_url_for",
|
||||
return_value=card_url,
|
||||
):
|
||||
out = _enrich_inbound_for_agent({
|
||||
"activity_id": "act-2",
|
||||
"text": "ping",
|
||||
"peer_id": PEER_UUID,
|
||||
"kind": "peer_agent",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-05T11:01:00Z",
|
||||
})
|
||||
|
||||
assert out["peer_name"] == "ops-agent"
|
||||
assert out["peer_role"] == "sre"
|
||||
assert out["agent_card_url"] == card_url
|
||||
|
||||
|
||||
def test_peer_agent_cache_miss_still_gets_agent_card_url():
|
||||
"""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."""
|
||||
from a2a_tools import _enrich_inbound_for_agent
|
||||
|
||||
card_url = f"https://platform.example/registry/{PEER_UUID}/agent-card"
|
||||
|
||||
with patch(
|
||||
"a2a_client.enrich_peer_metadata_nonblocking",
|
||||
return_value=None, # cache miss
|
||||
), patch(
|
||||
"a2a_client._agent_card_url_for",
|
||||
return_value=card_url,
|
||||
):
|
||||
out = _enrich_inbound_for_agent({
|
||||
"activity_id": "act-3",
|
||||
"text": "ping",
|
||||
"peer_id": PEER_UUID,
|
||||
"kind": "peer_agent",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-05T11:02:00Z",
|
||||
})
|
||||
|
||||
assert "peer_name" not in out
|
||||
assert "peer_role" not in out
|
||||
assert out["agent_card_url"] == card_url
|
||||
|
||||
|
||||
def test_peer_agent_a2a_client_unavailable_degrades_gracefully(monkeypatch):
|
||||
"""If a2a_client can't be imported (test harness, partial install),
|
||||
return the bare envelope — agent still gets text + peer_id + kind +
|
||||
activity_id, just without the friendly identity."""
|
||||
from a2a_tools import _enrich_inbound_for_agent
|
||||
|
||||
# Stub a2a_client import to fail.
|
||||
real_module = sys.modules.pop("a2a_client", None)
|
||||
fake = types.ModuleType("a2a_client")
|
||||
# Deliberately omit enrich_peer_metadata_nonblocking and
|
||||
# _agent_card_url_for so the helper's fallback path fires.
|
||||
sys.modules["a2a_client"] = fake
|
||||
|
||||
try:
|
||||
out = _enrich_inbound_for_agent({
|
||||
"activity_id": "act-4",
|
||||
"text": "ping",
|
||||
"peer_id": PEER_UUID,
|
||||
"kind": "peer_agent",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-05T11:03:00Z",
|
||||
})
|
||||
finally:
|
||||
if real_module is not None:
|
||||
sys.modules["a2a_client"] = real_module
|
||||
else:
|
||||
sys.modules.pop("a2a_client", None)
|
||||
|
||||
# Bare envelope passes through — receiving agent still has enough
|
||||
# to act, even if the friendly identity is missing.
|
||||
assert out["peer_id"] == PEER_UUID
|
||||
assert out["text"] == "ping"
|
||||
assert out["kind"] == "peer_agent"
|
||||
assert "peer_name" not in out
|
||||
assert "peer_role" not in out
|
||||
assert "agent_card_url" not in out
|
||||
Loading…
Reference in New Issue
Block a user