Skip to content

fix(types.events): CustomEvent extends BaseEvent so custom-event routing survives the wire - #52

Open
laithalsaadoon wants to merge 1 commit into
strands-labs:mainfrom
laithalsaadoon:upstream/1.2-events-custom-routing
Open

laithalsaadoon wants to merge 1 commit into
strands-labs:mainfrom
laithalsaadoon:upstream/1.2-events-custom-routing

Conversation

@laithalsaadoon

Copy link
Copy Markdown
Contributor

Summary

CustomEvent now extends BaseEvent, so it declares the same routing fields every other event declares (id, timestamp, thread_id, thread_name, message_id) and they survive serialization. Before this change LocalWorker._route_event stamped thread_id onto an attribute the model did not declare, which worked in one process but was dropped by EVENT_ADAPTER on the way to the wire. Over a CoordinatorClient, every custom event arrived with thread_id=None: filtered subscriptions (coord.on(cb, thread_id=...)) never fired for custom events, get_events(thread_id) returned them unrouted, and a client-side append_event(CustomEvent(...)) was rejected by the remote coordinator and only logged.

Closes #48.

What changed

  • types/events.py: CustomEvent(BaseEvent) with kind and payload; Event becomes Annotated[SystemEvent | CustomEvent, Field(union_mode="left_to_right")]. The discriminated SystemEvent union is tried first and CustomEvent is the fallback for unknown kinds only. Smart-mode union resolution would otherwise tie-break by the number of fields set, and a CustomEvent that now declares every routing field would outscore a fieldless concrete variant such as StartedEvent.
  • The serializer emits the declared fields alongside the flattened payload. A payload entry whose key shadows a declared field (id, thread_id, ...) is re-nested under a payload key so it cannot overwrite the event's own routing on a round trip. (codex_plan emits a payload entry named id.)
  • network/client.py: CoordinatorClient.append_event stays fire-and-forget and records each failure on a new public append_errors property (last 32, oldest first) at ERROR level, so a caller can observe a rejected append instead of only finding it in a log.
  • ai_thread/summarization.py, cli/commands.py, runtime/usage.py, runtime/worker.py: read the now-declared fields directly instead of getattr fallbacks.
  • spec/ stubs mirror the new shapes; stubtest-allowlist.txt gains the Event union entry, mirroring the existing SystemEvent one.

Behavior changes

  • A top-level key naming a routing field binds to that field: CustomEvent(kind=..., thread_id=x) routes rather than filling payload.
  • CustomEvent inherits BaseEvent's frozen config. A caller that mutated an instance in place must use model_copy(update=...).

Wire compatibility

  • An old peer parsing a new frame sees id/timestamp/thread_id/thread_name/message_id as unknown keys and sweeps them into payload, exactly as it sweeps any other unknown key today.
  • A new peer parsing an old frame gets the routing fields at their defaults (a fresh id, a now() timestamp, thread_id=None), which the runtime gate then stamps as before.

Tests

tests/test_events_custom_wire.py (new) covers three layers: the adapter keeps routing on a stamped custom event and re-nests shadowing payload keys; the runtime gate stamps the declared field; a real CoordinatorEndpoint plus CoordinatorClient agree on routing (filtered subscription fires, get_events returns the id, client-side append_event lands, a rejected append shows up on append_errors). tests/test_pure_functions.py and tests/test_reconstruct_roundtrip.py gain the union-order and round-trip cases.

Gate

Run on this branch (23c9ce39, base 1125f0c4), mcp 2.1.1:

ruff format --check src tests   1 file would be reformatted (pre-existing on main: src/ai_functions/memory/base.py), 108 already formatted
hatch run lint                  All checks passed!
hatch run check-spec            Success: no issues found in 80 modules
hatch run test                  487 passed, 2 skipped

…ing survives the wire

CustomEvent declares the routing fields every other event declares (id, timestamp,
thread_id, thread_name, message_id), so EVENT_ADAPTER keeps thread_id in the wire dict
instead of sweeping it into payload and dropping it. Over a CoordinatorClient a custom
event now matches a subscription filtered by thread_id, comes back from
get_events(thread_id) with that id set, and is accepted by the remote coordinator's
append_event; unrouted custom events were previously the only events a filtered
subscriber could never see.

Event becomes Annotated[SystemEvent | CustomEvent, Field(union_mode="left_to_right")].
Smart mode tie-breaks two matching union members by the number of fields set, and
CustomEvent now declares every routing field, so it would outscore a fieldless concrete
variant such as StartedEvent and swallow a built-in kind. Left-to-right keeps the
discriminated SystemEvent union as the first attempt and CustomEvent as the fallback for
unknown kinds only; the allowlist entry mirrors types.events.SystemEvent's.

The serializer emits the declared fields alongside the flattened payload and re-nests
payload entries whose keys shadow a declared field under a "payload" key, so a payload
entry named id or thread_id (codex_plan emits one) cannot overwrite the event's own
routing on a round trip.

Breaking: a top-level key naming a routing field binds to that field, so
CustomEvent(kind=..., thread_id=x) routes rather than filling payload; and CustomEvent
inherits BaseEvent's frozen config, so a caller that mutated an instance must use
model_copy(update=...) instead.

An old peer parses a new frame unchanged: its CustomEvent has no routing fields declared,
so it sweeps id/timestamp/thread_id/thread_name/message_id into payload exactly as it
sweeps any other unknown key. A new peer parses an old frame with the routing fields at
their defaults (a fresh id, now() timestamp, thread_id None), which the runtime gate then
stamps.

CoordinatorClient.append_event stays fire-and-forget and records each failure on the
public append_errors property (last 32, oldest first) at ERROR, so a caller can observe a
rejected append instead of only finding it in a log.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CustomEvent loses thread_id (and any extra routing field) when it crosses the CoordinatorClient wire

1 participant