Rohan03 commited on
Commit
fb16a26
·
verified ·
1 Parent(s): 7d8b2b6

Sprint 4C: AG-UI adapter — PAEvent to frontend stream + SSE helper

Browse files
Files changed (1) hide show
  1. purpose_agent/protocols/agui.py +149 -0
purpose_agent/protocols/agui.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agui.py — AG-UI protocol adapter for Purpose Agent.
3
+
4
+ Maps PAEvent stream → AG-UI compatible lifecycle/text/tool/state events.
5
+ Provides SSE endpoint helper for FastAPI/Starlette integration.
6
+
7
+ AG-UI event categories:
8
+ - Lifecycle: run_started, run_finished, run_error, agent_started, agent_finished
9
+ - Text: text_delta, text_done
10
+ - Tool: tool_call_start, tool_call_args, tool_call_end
11
+ - State: state_snapshot, state_delta
12
+ - Human: human_input_needed, human_input_received
13
+ - Custom: reasoning_summary, memory_update, skill_update
14
+
15
+ Bidirectional:
16
+ - Emit events TO the frontend
17
+ - Receive human approval events FROM the frontend
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, AsyncIterator
25
+
26
+ from purpose_agent.runtime.events import PAEvent, EventKind, Visibility
27
+
28
+ # AG-UI event type mapping
29
+ _EVENT_MAP = {
30
+ EventKind.RUN_STARTED: "lifecycle.run_started",
31
+ EventKind.RUN_FINISHED: "lifecycle.run_finished",
32
+ EventKind.RUN_ERROR: "lifecycle.run_error",
33
+ EventKind.AGENT_STARTED: "lifecycle.agent_started",
34
+ EventKind.AGENT_FINISHED: "lifecycle.agent_finished",
35
+ EventKind.AGENT_ERROR: "lifecycle.agent_error",
36
+ EventKind.TEXT_DELTA: "text.delta",
37
+ EventKind.TEXT_DONE: "text.done",
38
+ EventKind.TOOL_STARTED: "tool.call_start",
39
+ EventKind.TOOL_ARGS: "tool.call_args",
40
+ EventKind.TOOL_RESULT: "tool.call_end",
41
+ EventKind.TOOL_ERROR: "tool.call_error",
42
+ EventKind.STATE_SNAPSHOT: "state.snapshot",
43
+ EventKind.STATE_DELTA: "state.delta",
44
+ EventKind.REASONING_SUMMARY: "custom.reasoning",
45
+ EventKind.HUMAN_APPROVAL_REQUESTED: "human.input_needed",
46
+ EventKind.HUMAN_APPROVAL_RECEIVED: "human.input_received",
47
+ EventKind.MEMORY_PROMOTED: "custom.memory_update",
48
+ EventKind.SKILL_UPDATED: "custom.skill_update",
49
+ EventKind.CHECKPOINT_SAVED: "custom.checkpoint",
50
+ }
51
+
52
+
53
+ @dataclass
54
+ class AGUIEvent:
55
+ """AG-UI formatted event for frontend consumption."""
56
+ type: str
57
+ run_id: str
58
+ lane_id: str = "main"
59
+ timestamp: float = field(default_factory=time.time)
60
+ data: dict[str, Any] = field(default_factory=dict)
61
+
62
+ def to_sse(self) -> str:
63
+ """Format as Server-Sent Event."""
64
+ payload = json.dumps(self.to_dict(), default=str)
65
+ return f"data: {payload}\n\n"
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ return {
69
+ "type": self.type,
70
+ "runId": self.run_id,
71
+ "laneId": self.lane_id,
72
+ "timestamp": self.timestamp,
73
+ "data": self.data,
74
+ }
75
+
76
+
77
+ class AGUIAdapter:
78
+ """
79
+ Adapter that converts PAEvent stream to AG-UI protocol.
80
+
81
+ Usage:
82
+ adapter = AGUIAdapter()
83
+
84
+ # Convert single event
85
+ agui_event = adapter.convert(pa_event)
86
+
87
+ # Stream conversion (async)
88
+ async for agui_event in adapter.stream(pa_event_iterator):
89
+ yield agui_event.to_sse()
90
+
91
+ # FastAPI/Starlette SSE endpoint
92
+ @app.get("/stream/{run_id}")
93
+ async def stream(run_id: str):
94
+ return StreamingResponse(
95
+ adapter.sse_generator(event_bus.subscribe(run_id=run_id)),
96
+ media_type="text/event-stream"
97
+ )
98
+ """
99
+
100
+ def __init__(self, include_internal: bool = False):
101
+ self._include_internal = include_internal
102
+
103
+ def convert(self, event: PAEvent) -> AGUIEvent | None:
104
+ """Convert a single PAEvent to AG-UI format. Returns None for filtered events."""
105
+ # Filter by visibility
106
+ if event.visibility == Visibility.DEBUG:
107
+ return None
108
+ if event.visibility == Visibility.INTERNAL and not self._include_internal:
109
+ return None
110
+
111
+ # Safety: reject events with hidden chain-of-thought
112
+ if event.has_hidden_cot():
113
+ return None
114
+
115
+ # Map event kind
116
+ agui_type = _EVENT_MAP.get(event.kind)
117
+ if not agui_type:
118
+ agui_type = f"custom.{event.kind.value.replace('.', '_')}"
119
+
120
+ return AGUIEvent(
121
+ type=agui_type,
122
+ run_id=event.run_id,
123
+ lane_id=event.lane_id,
124
+ timestamp=event.ts,
125
+ data=event.payload,
126
+ )
127
+
128
+ async def stream(self, events: AsyncIterator[PAEvent]) -> AsyncIterator[AGUIEvent]:
129
+ """Convert async PAEvent stream to AG-UI event stream."""
130
+ async for event in events:
131
+ agui = self.convert(event)
132
+ if agui:
133
+ yield agui
134
+
135
+ async def sse_generator(self, events: AsyncIterator[PAEvent]) -> AsyncIterator[str]:
136
+ """Generate SSE-formatted strings for HTTP streaming."""
137
+ async for event in events:
138
+ agui = self.convert(event)
139
+ if agui:
140
+ yield agui.to_sse()
141
+
142
+ def format_sse_batch(self, events: list[PAEvent]) -> str:
143
+ """Format a batch of events as SSE (for testing/debugging)."""
144
+ lines = []
145
+ for event in events:
146
+ agui = self.convert(event)
147
+ if agui:
148
+ lines.append(agui.to_sse())
149
+ return "".join(lines)