From 59b24ac8b9ce12dc2a33f4f4b61b07c7e91bd51a Mon Sep 17 00:00:00 2001 From: nishanthvonteddu Date: Thu, 20 Aug 2026 18:09:29 -0700 Subject: [PATCH] Events: stamp who caused them, so the loop guards can fire `EventEnvelope.actor` is read by both self-trigger guards and defaults to None. No producer ever set it: not cron_event, not webhook_event, not gmail_pubsub_event, and not the channel-message route. Both guards test the field first and admit when it is empty: if actor and actor in self.self_actors: # governor return bool(actor) and actor in subscription.ignore_actors # engine So `S16_SELF_ACTORS` and every subscription's `ignore_actors` were unreachable, and the self-sustaining loop they exist to refuse -- an agent answering on a channel it also watches -- had nothing standing in its way. The guards have unit tests, but those build envelopes by hand with `actor=` already set, so the mechanism was proven and the wiring never was. Producers now stamp it: the sending identity for a channel message, the watched mailbox for a Gmail push, the schedule for a cron tick, the source for a webhook, each overridable by the caller. --- s16code/events/adapters.py | 25 +++++++++++++++++++------ s16code/routes.py | 4 ++++ tests/test_channel_connections.py | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/s16code/events/adapters.py b/s16code/events/adapters.py index 2a5b0c7..93fca5e 100644 --- a/s16code/events/adapters.py +++ b/s16code/events/adapters.py @@ -1,4 +1,10 @@ -"""Thin normalizers. Transport-specific code ends at EventEnvelope.""" +"""Thin normalizers. Transport-specific code ends at EventEnvelope. + +Every producer stamps ``actor``: who caused this fact. Both loop guards -- +``AutonomyGovernor.admit_event`` and ``AutonomousEventEngine._self_caused`` -- +read that field and admit the event when it is empty, so a producer that leaves +it unset silently disables them. +""" from __future__ import annotations @@ -11,16 +17,19 @@ def cron_event(schedule_id: str, *, occurred_at: datetime | None = None, - data: dict[str, Any] | None = None) -> EventEnvelope: + data: dict[str, Any] | None = None, actor: str | None = None) -> EventEnvelope: instant = occurred_at or datetime.now(UTC) return EventEnvelope(id=f"{schedule_id}:{instant.isoformat()}", source=f"cron.{schedule_id}", - type="schedule.tick", subject=schedule_id, occurred_at=instant, data=data or {}) + type="schedule.tick", subject=schedule_id, occurred_at=instant, data=data or {}, + actor=actor or f"cron.{schedule_id}") def webhook_event(*, source: str, delivery_id: str, event_type: str, - payload: dict[str, Any], occurred_at: datetime | None = None) -> EventEnvelope: + payload: dict[str, Any], occurred_at: datetime | None = None, + actor: str | None = None) -> EventEnvelope: return EventEnvelope(id=delivery_id, source=source, type=event_type, - occurred_at=occurred_at or datetime.now(UTC), data=payload) + occurred_at=occurred_at or datetime.now(UTC), data=payload, + actor=actor or source) def gmail_pubsub_event(message: dict[str, Any]) -> EventEnvelope: @@ -38,4 +47,8 @@ def gmail_pubsub_event(message: dict[str, Any]) -> EventEnvelope: occurred = datetime.fromisoformat(str(published).replace("Z", "+00:00")) if published else datetime.now(UTC) return EventEnvelope(id=str(wrapped.get("messageId") or f"gmail-{payload['historyId']}"), source="gmail.pubsub", type="gmail.history.changed", - subject=str(payload["emailAddress"]), occurred_at=occurred, data=payload) + subject=str(payload["emailAddress"]), occurred_at=occurred, data=payload, + # The watched mailbox. An agent that answers from the same + # address sees its own reply arrive here, which is the loop + # `S16_SELF_ACTORS` exists to refuse. + actor=f"gmail:{payload['emailAddress']}") diff --git a/s16code/routes.py b/s16code/routes.py index ee09f81..15bdd7c 100644 --- a/s16code/routes.py +++ b/s16code/routes.py @@ -224,6 +224,10 @@ async def channel_message( "observed_at": datetime.now(UTC).isoformat(), "data": body.model_dump(mode="json"), "traceparent": None, + # Who sent it. An agent that answers on a channel it also watches sees + # its own reply arrive as a new message; naming the sender is what lets + # `S16_SELF_ACTORS` and a subscription's `ignore_actors` refuse it. + "actor": f"{body.channel}:{body.channel_user_id}", } from s16code.events import EventEnvelope diff --git a/tests/test_channel_connections.py b/tests/test_channel_connections.py index 4cb238b..2e13e94 100644 --- a/tests/test_channel_connections.py +++ b/tests/test_channel_connections.py @@ -265,3 +265,23 @@ async def fake_gateway(_app, prompt: str, system: str): channel="future_adapter", recipient_id="destination", text="The build passed.", thread_id=None, voice_audio_ref=None, ) + + +def test_channel_event_records_who_sent_it(app_client, monkeypatch): + """Every producer must stamp `actor`, or the self-trigger guard is unreachable. + + `AutonomyGovernor.admit_event` and `AutonomousEventEngine._self_caused` both + read `event.actor` and both short-circuit when it is empty. The guard has + unit tests, but they build envelopes by hand with `actor=` already set, so + nothing covered the wiring: no production producer filled the field, and the + loop protection could never fire on a real message. + """ + monkeypatch.setenv("S16_CHANNEL_BRIDGE_TOKEN", "shared") + headers = {"Authorization": "Bearer shared"} + + app_client.post("/v1/agent/channel-messages", headers=headers, json=_channel_message()) + + events = app_client.get("/v1/agent/events").json()["events"] + envelope = events[-1]["event"] + assert envelope["actor"], "a channel event must say who sent it" + assert "42" in envelope["actor"]