Repository navigation
feat(agents): peer-to-peer messaging and idle notice (#16948, #16949) - #16996
Conversation
A peer agent can now reach another by its stable presence name (protocols/peer_inbox.py) instead of only through a human: delivery is scoped by tenant (never crosses tenants, and UNKNOWN_TENANT / EXTERNAL identities are never addressable) and drained at each kind's real per-turn boundary, never mid-task -- AI_STACK's chat_workflow/tool_handler.py::_dispatch_tool_call and SESSION's services/agent_terminal/service.py::execute_command, the same seams mapped for #16947's busy/idle signal. A peer message carries no approval of its own: a sensitive tool call it triggers still goes through the existing human-approval gate unchanged. protocols/idle_notice.py adds the other half of #16949: a one-shot notice the moment a busy peer's presence transitions to idle, built on the existing EventBus (events/bus.py) rather than a new transport, with a bounded wait (AUTOBOT_IDLE_NOTICE_TIMEOUT_SECONDS) so a waiter is told explicitly on expiry instead of blocking forever.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (2)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code review on PR #16996 found that sync_ai_stack_presence (#16947) reports every AgentHealthRegistry role -- "chat", "rag" and "system_commands" -- as its own addressable AI_STACK presence entry, but the only drain wired for #16948 is chat_workflow's "chat" seam. Addressing "rag" or "system_commands" succeeded silently and then lost the message forever, since nothing ever drains those inboxes: rag_agent.py and system_command_agent.py run their own StandardizedAgent.process_request flow, with no relationship to chat_workflow's dispatch seam. PeerInboxDirectory.send() now refuses any AI_STACK name outside the addressable set with the same RecipientNotAddressableError already used for tenant/EXTERNAL refusals, so misaddressing fails loud instead of silently. Filed #16997 (sub-issue of #16946) to map rag/system_commands' own real turn boundary and lift the restriction once wired.
|
Code-reviewer pass complete. One HIGH finding, fixed: AI_STACK peer-message drain was hardcoded to Fixed in Everything else the review checked out sound: the recipient-tenant-key resolution in Re-verified after the fix: 64 tests passing, file-size ratchet clean ( |
… issue-16948-16949-peer-messaging-merge # Conflicts: # autobot_shared/env_registry_agent_runtime.py
… issue-16948-16949-peer-messaging-merge2
Thinking Path
#16947 gave every agent kind a live, named presence entry with a busy/idle
signal but no way to reach another entry directly -- a peer could only be
observed, not addressed. #16948/#16949 add that address book and the two
things it needs to be safe: delivery that respects tenancy and turn
boundaries, and a push notice for the busy->idle transition so a waiter
doesn't have to poll presence.
The two hard calls, both made explicit in the assignment and carried through
here:
protocols/peer_inbox.pydelivers into a per-
(kind, tenant_id, name)inbox with no side channelinto any approval path. A sensitive tool call a peer message triggers goes
through the exact same gate a human-triggered one does --
AI_STACK's
enforce_work_item_approval(already unconditional on everytool call, so it needed no new gating code) and SESSION's own
approval_handler/assess_command_risk, unchanged.call sits at the one seam each kind's production callers all share:
AI_STACK's
chat_workflow/tool_handler.py::_dispatch_tool_callandSESSION's
services/agent_terminal/service.py::execute_command-- the sameseams mapped and confirmed for feat(agents): live presence — one registry of named agents with busy/idle state, across all three kinds #16947's busy/idle wiring, reused rather than
re-derived. A message that arrives after one drain sits queued until the
next.
protocols/idle_notice.py(#16949) is built on the existingevents/bus.pytransport rather than a new one:
AgentPresenceRegistry.report()nowpublishes
EVT_AGENT_IDLEon exactly abusy=True -> busy=Falsetransition,and
wait_for_idle()is the one-shot subscriber -- resolves immediately foran already-idle target, otherwise waits for one matching event or a bounded
timeout (
AUTOBOT_IDLE_NOTICE_TIMEOUT_SECONDS), always unsubscribing.What Changed
protocols/peer_inbox.py(new):PeerMessageEntry,PeerInbox(deliver/drain),
PeerInboxDirectory.send()-- addresses by the presence registry'sown
(kind, name), resolves the recipient's real tenant from the matchedPresenceEntry(never from the caller), refuses when the name isn't live(
RecipientNotAddressableError);EXTERNALandUNKNOWN_TENANTare neverreachable because
list_live()never surfaces them.chat_workflow/tool_dispatch_guards.py: newenforce_peer_messages(ctx)--drains the
chatAI_STACK inbox intoctx.context["peer_messages"], ano-op with no
ctx.chat_workflow/tool_handler.py:_dispatch_tool_callcalls the new guardfirst, alongside the existing six unconditional guards.
services/agent_terminal/service.py: new_drain_peer_messages(session),called at the top of
execute_commandbefore command assessment; keys theinbox lookup by
session.tenant_id or UNKNOWN_TENANT, mirroringsync_session_presence's own derivation exactly (a rawNonekey wouldsilently miss anything
send()actually authorized).protocols/idle_notice.py(new):notify_agent_idle()(fire-and-forgetpublish, safe no-op with no running loop),
wait_for_idle(),IdleWaitExpiredError.protocols/agent_presence.py:report()now callsnotify_agent_idle()on a
busy: True -> Falsetransition only.autobot_shared/env_registry_agent_runtime.py: registersAUTOBOT_IDLE_NOTICE_TIMEOUT_SECONDS(default 300s);docs/developer/ENV_VARS.mdregenerated from the registry.
protocols/peer_inbox_test.py(9),protocols/idle_notice_test.py(9),
chat_workflow/peer_messages_dispatch_16948_test.py(4, including theAC4 mid-task-vs-next-boundary case),
services/agent_terminal/peer_messages_dispatch_16948_test.py(5, including an
execute_command-level integration test and theUNKNOWN_TENANTkey-matching regression).scripts/python_file_size_known_large.py/repo_tests/python_file_size_ratchet_baseline.py:services/agent_terminal/service.pyceiling lowered 956 -> 951 (commenttrimming offset the new drain call and helper).
Verification
python3 scripts/check_python_file_size.py --audit-ceilings-- 5563 filesscanned, 488 grandfathered, all live and at size.
python3 pipeline-scripts/check_env_var_registry.py-- exit 0.python3 -m black --check,isort --check,flake8,bandit -qon everychanged/new file -- all clean.
bash pipeline-scripts/detect-hardcoded-values.shon every changed/new file --SSOT Coverage: pass (new=0, ...).detect-secrets scan(no baseline write) on every changed/new file -- nofindings;
.secrets.baselineunmodified.jscpd@5.0.6reproduction of the duplication-guard's exact invocation:11718 duplicated lines, under the 11723 pin, unchanged from the pre-PR
baseline -- no new duplication.
wait_for_idle()/notify_agent_idle()end to end against the realEventBus(not mocked): caught and fixed a real bug where the in-processlistener payload is nested as
{"type", "payload"}byEventManager.publish, which the first draft's listener didn't unwrap andwould have silently never matched in production.
push.
Risks
(
ChatWorkflowManageris alazy_singletonwith no persistent per-rolerun), so a peer message can only be drained on the next inbound chat
message for that process, not proactively woken -- tracked separately as
decision(agents): should a peer message wake an idle AI-stack agent to start a new run? #16991 (
needs-decision).exists in the heartbeat scheduler) -- tracked as feat(agents): deliver a peer message at the start of a Company OS agent's next heartbeat run #16992,
blocked_by #16948.fix(security): A2A trust keyed on the verified pair, capabilities enforced where they apply (#16957, #16950) #16969) are out of scope by explicit instruction.
Model Used
Claude Sonnet 5 (claude-sonnet-5)
Issue Link
Refs #16948
Changelog fragment
changelog/unreleased/16948-16949-peer-messaging.mdChecklist
ENV_VARS.md)🤖 Generated with Claude Code
Single-issue rationale
This PR closes no issue yet. #16949 (the idle-notice half) has been split
out into #17015, which lands independently. #16948's own send side is
blocked by #16986 (
protocols/agent_communication's consolidation, perthe review's "consolidate, never fork" ruling on peer_inbox.py) --
tracked as a
blocked_byedge on issue #16948. This PR stays onRefsuntil #16986 lands and an end-to-end test through the real transport
passes.