multi-agent-lab / docs /architecture /observer-pattern.md
agharsallah
feat: add Fishbowl UI ADR and implementation plan for Gradio presenter layer
a13e4e8
|
Raw
History Blame Contribute Delete
3.78 kB
# Observer Pattern β€” Decoupled Rendering
## Design contract
The observer is the camera crew. It has exactly one rule: **never append events**.
```mermaid
flowchart LR
Conductor --> Agents
Agents -->|append| Ledger[("Ledger")]
Ledger -->|read only| Obs["Observer.consume()"]
Obs --> Diff["ViewDiff β€” the delta"]
Diff --> Out["UI callback Β· SSE Β· WebSocket"]
```
The cognitive loop (left) writes; the observer (right) only reads. The world runs
identically whether or not any observer is attached.
The cognitive loop runs identically whether or not any observer is attached.
The world is not "observed into existence" β€” it runs, and the observer watches.
---
## What this buys you
### 1. Multiple renderers off one ledger
```python
stage_observer = Observer() # renders the Gradio stage markdown
graph_observer = Observer() # renders the cognition graph
chatlog_observer = Observer() # renders a plain chat log
# All three subscribe to the same ledger; all three update independently
conductor = Conductor(scenario, observer=stage_observer)
```
### 2. Record and replay
Because the ledger is the only source of truth:
```python
# Record a run by keeping the SQLiteLedger file.
# Replay by feeding saved events to a fresh observer:
for event in saved_ledger.events:
replay_observer.consume(event)
```
The UI can replay any past run, pause it, or fast-forward to a specific turn.
### 3. Testable rendering
The stage is a pure function of the ledger:
```python
# Projections (and therefore observer views) are deterministically testable
events = (world_observed_event, judge_verdict_event)
obs = Observer()
for e in events:
obs.consume(e)
assert obs.view.judge_notes == ["keep it"]
```
---
## ViewDiff β€” streaming the delta
Instead of re-rendering the full state each turn, the observer computes a diff:
```python
@dataclass
class ViewDiff:
scene_changed: bool
new_scene: str
new_agent_notes: list[str]
new_judge_notes: list[str]
new_user_artifacts: list[str]
@property
def has_changes(self) -> bool: ...
```
Only the diff is sent to the client β€” the right shape for SSE/WebSocket streaming.
---
## Gradio integration (current)
Currently the Gradio UI re-renders by calling `conductor.projection` after each step.
This works but does a full rebuild from the ledger on every render.
The upgrade path (Phase 3):
1. Attach an observer to the conductor at startup.
2. Use `gr.State` + `gr.update()` from the observer callbacks.
3. For real-time streaming, wire a generator-based Gradio output to the observer callback queue.
```python
# Future pattern (Phase 3)
obs = Observer()
conductor = Conductor(scenario, observer=obs)
def stream_step():
conductor.step()
# obs.consume() was called inside conductor._append()
# retrieve the latest diff from obs
diff = obs.latest_diff()
if diff.scene_changed:
yield gr.update(value=render_stage(obs.view))
```
This upgrade is realized by the **Fishbowl UI** β€” see
[ADR-0021](../adr/0021-fishbowl-ui-gradio-presenter.md) and the plan in
[next-steps/fishbowl-ui.md](next-steps/fishbowl-ui.md) β€” where the Show is a `gr.HTML`
stage re-rendered by a `gr.Timer` over a presenter that derives per-agent state from
the ledger (a new, pure projection alongside `rebuild_stage`).
---
## Callback safety
Callbacks are synchronous and called inline during `consume()`.
They must not:
- Append events to the ledger (breaks the read/write separation)
- Block for more than a few milliseconds (blocks the conductor loop)
- Raise exceptions unchecked (propagates into the conductor)
The `Observer` class does not catch callback exceptions β€” the caller is responsible
for wrapping callbacks in try/except if they can fail.