|
| 1 | +# `events$` on `Agent` Contract Design |
| 2 | + |
| 3 | +## Goal |
| 4 | + |
| 5 | +Replace the optional, free-form `customEvents$?: Observable<AgentCustomEvent>` on the `Agent` contract with a required, structured `events$: Observable<AgentEvent>` carrying a discriminated union. Codify the invariant: **state lives on signals, events live on `events$`, neither duplicates the other.** |
| 6 | + |
| 7 | +## Motivation |
| 8 | + |
| 9 | +The current `customEvents$` is the only event-shaped concern on the contract today. It is: |
| 10 | + |
| 11 | +- **Optional** — adapters may omit it, forcing every consumer to null-check before subscribing. |
| 12 | +- **Free-form** — `{ type: string; [key: string]: unknown }` lets any field name flow through, but provides no type-narrowing for known event types like `state_update`. |
| 13 | +- **Single example, no growth path** — the addition of more event-shaped concerns has been informally proposed (e.g., as part of broader course-correction discussions on AG-UI alignment). Without a structured union, each addition would either be a string-literal convention buried in handler code or a separate optional Observable on the contract. |
| 14 | + |
| 15 | +Codifying `events$` as required + structured does three things: |
| 16 | + |
| 17 | +1. **Removes optionality friction.** Every consumer can subscribe directly with no presence check. |
| 18 | +2. **Makes well-known event types type-safe.** The current `chat.component.ts` handler does `if (event.type === 'state_update') { ... }` and trusts the payload shape; with the structured union, TypeScript narrows the variant. |
| 19 | +3. **Establishes the duplication invariant.** State-bearing concerns (`messages`, `toolCalls`, `status`, `interrupt`, `subagents`, `state`, `history`) stay on signals. Events on `events$` carry only things that are not derivable from signals. |
| 20 | + |
| 21 | +## Architecture |
| 22 | + |
| 23 | +### Contract change |
| 24 | + |
| 25 | +```ts |
| 26 | +// libs/chat/src/lib/agent/agent-event.ts (new file) |
| 27 | +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 |
| 28 | + |
| 29 | +/** |
| 30 | + * Render-state-store sync event. Adapters emit this when the runtime |
| 31 | + * publishes a state-snapshot intended for the chat library's render store |
| 32 | + * (used by generative UI and a2ui surfaces). |
| 33 | + */ |
| 34 | +export interface AgentStateUpdateEvent { |
| 35 | + readonly type: 'state_update'; |
| 36 | + readonly data: Record<string, unknown>; |
| 37 | +} |
| 38 | + |
| 39 | +/** |
| 40 | + * Escape hatch for runtime-specific or user-defined events that do not |
| 41 | + * (yet) have a well-known structured variant. `name` carries the runtime |
| 42 | + * event name; `data` carries the payload verbatim. |
| 43 | + */ |
| 44 | +export interface AgentCustomEvent { |
| 45 | + readonly type: 'custom'; |
| 46 | + readonly name: string; |
| 47 | + readonly data: unknown; |
| 48 | +} |
| 49 | + |
| 50 | +export type AgentEvent = AgentStateUpdateEvent | AgentCustomEvent; |
| 51 | +``` |
| 52 | + |
| 53 | +```ts |
| 54 | +// libs/chat/src/lib/agent/agent.ts (delta) |
| 55 | +import type { Observable } from 'rxjs'; |
| 56 | +import type { AgentEvent } from './agent-event'; |
| 57 | + |
| 58 | +export interface Agent { |
| 59 | + // ...all existing signals unchanged... |
| 60 | + events$: Observable<AgentEvent>; // required; replaces customEvents$ |
| 61 | + // ...submit, stop unchanged... |
| 62 | +} |
| 63 | +``` |
| 64 | + |
| 65 | +### Removed types |
| 66 | + |
| 67 | +- `AgentCustomEvent` (the previous free-form `{type: string, [k]: unknown}`) is **deleted**. The new `AgentCustomEvent` (structured `{type: 'custom', name, data}`) reuses the symbol — semantically the escape-hatch case is what the old one always was, but typed. |
| 68 | + |
| 69 | +The collision in name is intentional: the structured variant **is** the spiritual successor. Any consumer that imported `AgentCustomEvent` updates their usage from `event.type === 'foo'` (free) to `event.type === 'custom' && event.name === 'foo'` (structured). |
| 70 | + |
| 71 | +### File renames |
| 72 | + |
| 73 | +- `libs/chat/src/lib/agent/agent-custom-event.ts` → `agent-event.ts` |
| 74 | +- `libs/chat/src/lib/agent/agent-custom-event.spec.ts` → `agent-event.spec.ts` |
| 75 | + |
| 76 | +### Required vs optional |
| 77 | + |
| 78 | +Required. Adapters that have no event source pass `EMPTY` from RxJS: |
| 79 | + |
| 80 | +```ts |
| 81 | +import { EMPTY } from 'rxjs'; |
| 82 | + |
| 83 | +const agent: Agent = { |
| 84 | + // ... |
| 85 | + events$: EMPTY, |
| 86 | + // ... |
| 87 | +}; |
| 88 | +``` |
| 89 | + |
| 90 | +This is preferable to optionality because: |
| 91 | +- `EMPTY` is a one-line, free idiom for "this stream produces nothing". |
| 92 | +- Consumers never write `if (agent.events$) ...`. |
| 93 | +- Future event types added to the union benefit every adapter automatically; an adapter that wants to opt out of a specific event type just doesn't emit it. |
| 94 | + |
| 95 | +### Adapter behavior |
| 96 | + |
| 97 | +**LangGraph adapter (`libs/langgraph/src/lib/to-agent.ts`):** |
| 98 | + |
| 99 | +The existing `buildCustomEvents$(ref)` helper translates LangGraph's `CustomStreamEvent[]` signal into the new `AgentEvent` stream. The translation: |
| 100 | + |
| 101 | +```ts |
| 102 | +function toAgentEvent(e: CustomStreamEvent): AgentEvent { |
| 103 | + if (e.name === 'state_update' && isRecord(e.data)) { |
| 104 | + return { type: 'state_update', data: e.data }; |
| 105 | + } |
| 106 | + return { type: 'custom', name: e.name, data: e.data }; |
| 107 | +} |
| 108 | + |
| 109 | +function isRecord(v: unknown): v is Record<string, unknown> { |
| 110 | + return typeof v === 'object' && v !== null && !Array.isArray(v); |
| 111 | +} |
| 112 | +``` |
| 113 | + |
| 114 | +The bridge subject + cursor pattern (effect-driven, append-only-array → Observable) is preserved. Rename the helper to `buildEvents$` to match the new contract field name. |
| 115 | + |
| 116 | +**Mock helper (`libs/chat/src/lib/testing/mock-agent.ts`):** |
| 117 | + |
| 118 | +```ts |
| 119 | +export interface MockAgentOptions { |
| 120 | + // ...other options unchanged... |
| 121 | + events$?: Observable<AgentEvent>; // optional input; defaults to EMPTY |
| 122 | + // (drop) customEvents$ |
| 123 | +} |
| 124 | + |
| 125 | +export function mockAgent(opts: MockAgentOptions = {}): MockAgent { |
| 126 | + // ... |
| 127 | + return { |
| 128 | + // ...existing fields... |
| 129 | + events$: opts.events$ ?? EMPTY, |
| 130 | + // ... |
| 131 | + }; |
| 132 | +} |
| 133 | +``` |
| 134 | + |
| 135 | +**Conformance helper (`libs/chat/src/lib/testing/agent-conformance.ts`):** |
| 136 | + |
| 137 | +Replace the conditional `if (agent.customEvents$ !== undefined) ...` block with an unconditional assertion: |
| 138 | + |
| 139 | +```ts |
| 140 | +it('events$ is an Observable-like with .subscribe', () => { |
| 141 | + const agent = factory(); |
| 142 | + expect(typeof agent.events$.subscribe).toBe('function'); |
| 143 | +}); |
| 144 | +``` |
| 145 | + |
| 146 | +### Consumer migration |
| 147 | + |
| 148 | +Only one consumer in production: `libs/chat/src/lib/compositions/chat/chat.component.ts`. Today: |
| 149 | + |
| 150 | +```ts |
| 151 | +const stream$ = agent.customEvents$; |
| 152 | +if (!stream$) return; |
| 153 | +stream$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { |
| 154 | + if (event.type !== 'state_update') return; |
| 155 | + const data = event['data']; |
| 156 | + if (!data || typeof data !== 'object') return; |
| 157 | + // ...store.update(data as Record<string, unknown>); |
| 158 | +}); |
| 159 | +``` |
| 160 | + |
| 161 | +After: |
| 162 | + |
| 163 | +```ts |
| 164 | +agent.events$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { |
| 165 | + if (event.type !== 'state_update') return; |
| 166 | + // event.data is Record<string, unknown> — narrowed by the discriminator |
| 167 | + const store = this.resolvedStore(); |
| 168 | + if (!store) return; |
| 169 | + store.update(event.data); |
| 170 | +}); |
| 171 | +``` |
| 172 | + |
| 173 | +Cleaner: no presence check, no untyped index access, no manual shape guard. |
| 174 | + |
| 175 | +### Public API |
| 176 | + |
| 177 | +`libs/chat/src/public-api.ts`: |
| 178 | +- Remove: `AgentCustomEvent` (old free-form) export |
| 179 | +- Add: `AgentEvent`, `AgentStateUpdateEvent`, `AgentCustomEvent` (new structured) exports |
| 180 | + |
| 181 | +```ts |
| 182 | +export type { |
| 183 | + // ...other types unchanged... |
| 184 | + AgentEvent, |
| 185 | + AgentStateUpdateEvent, |
| 186 | + AgentCustomEvent, // now the structured escape-hatch variant |
| 187 | +} from './lib/agent'; |
| 188 | +``` |
| 189 | + |
| 190 | +## What's deliberately NOT in the union |
| 191 | + |
| 192 | +- **`tool_call_started/finished`** — already in `toolCalls: Signal<ToolCall[]>`; the array length and per-call `status` reflect lifecycle. |
| 193 | +- **`interrupt_raised/resolved`** — already in `interrupt?: Signal<AgentInterrupt | undefined>`; presence change reflects the lifecycle. |
| 194 | +- **`run_started/finished/errored`** — already in `status: Signal<AgentStatus>` and `error: Signal<unknown>`. |
| 195 | +- **`message_streamed`** — already in `messages: Signal<Message[]>`; partial deltas are reflected as in-place message content updates. |
| 196 | +- **`subagent_spawned`** — already in `subagents?: Signal<Map<string, Subagent>>`. |
| 197 | + |
| 198 | +Adding events for any of these would violate the no-duplication invariant. If a consumer needs "happened-once" semantics for one of these (e.g., "fire a toast when a tool call finishes"), they derive it from the signal via an Angular `effect` that compares previous/current values. |
| 199 | + |
| 200 | +## When to add new structured event types |
| 201 | + |
| 202 | +Triggers: |
| 203 | +- A second adapter (AG-UI) reveals an event pattern that isn't state-shaped (i.e., not a snapshot or current-value of any concern), and is used by enough consumers to deserve type narrowing. |
| 204 | +- A chat-library convention emerges (similar to `state_update`) that crosses the runtime/library boundary. |
| 205 | + |
| 206 | +Each addition is purely additive to the `AgentEvent` union — existing adapters that don't emit the new variant remain conformant. |
| 207 | + |
| 208 | +## Out of Scope |
| 209 | + |
| 210 | +- Backwards-compat alias `customEvents$` on `Agent`. Migration breaks compilation; consumers update at the same time. |
| 211 | +- Snapshot/replay semantics for late subscribers. State signals already carry current values; `events$` is delta-only. |
| 212 | +- Renaming `state_update` to a more chat-library-specific name. The convention is established and runtime-agnostic. |
| 213 | +- Adding non-event-shaped concerns (history, threads, etc.) to `events$`. |
| 214 | +- Renaming the bridge helper beyond `buildCustomEvents$` → `buildEvents$`. |
| 215 | + |
| 216 | +## Risk |
| 217 | + |
| 218 | +- **Breaking change to `customEvents$` shape and name.** Any external code subscribing to `agent.customEvents$` won't compile. Mitigated by the fact that only one production consumer exists today (`chat.component.ts`); the rest are tests and conformance helpers. |
| 219 | +- **`state_update` heuristic in LangGraph translation.** The `e.name === 'state_update'` branch is a string-literal convention — if a runtime emits `state_update` with non-`Record` data, we fall through to `custom`. Documented in the translator helper. |
0 commit comments