Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions s16code/events/adapters.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:
Expand All @@ -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']}")
4 changes: 4 additions & 0 deletions s16code/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions tests/test_channel_connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]