Skip to content

Commit 1222a0b

Browse files
committed
feat: add ThreadListSidebar component for managing conversations
feat: implement tool render components for AG-UI with various widgets feat: create useRunManager hook for managing agent run lifecycle feat: add CopilotKitProvider for AG-UI context integration test: implement e2e tests for conversation functionality and thread API Signed-off-by: Andre Bossard <anbossar@microsoft.com>
1 parent 593f45f commit 1222a0b

29 files changed

Lines changed: 3567 additions & 305 deletions
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""
2+
Agent Builder — AG-UI Event Conversion
3+
4+
Calculations: pure functions that convert ReAct execution data
5+
into AG-UI protocol events. No I/O, no database, no side effects.
6+
Each function takes data in, returns AG-UI event(s) out.
7+
"""
8+
9+
import json
10+
import uuid
11+
from typing import Any, Optional
12+
13+
from ag_ui.core import (
14+
CustomEvent,
15+
EventType,
16+
RunErrorEvent,
17+
RunFinishedEvent,
18+
RunStartedEvent,
19+
StepFinishedEvent,
20+
StepStartedEvent,
21+
TextMessageContentEvent,
22+
TextMessageEndEvent,
23+
TextMessageStartEvent,
24+
ToolCallArgsEvent,
25+
ToolCallEndEvent,
26+
ToolCallResultEvent,
27+
ToolCallStartEvent,
28+
StateSnapshotEvent,
29+
)
30+
from ag_ui.encoder import EventEncoder
31+
32+
33+
# Shared encoder — stateless, safe to reuse
34+
_encoder = EventEncoder()
35+
36+
37+
# ---------------------------------------------------------------------------
38+
# Pure conversion functions
39+
# ---------------------------------------------------------------------------
40+
41+
def run_started_event(thread_id: str, run_id: str) -> RunStartedEvent:
42+
return RunStartedEvent(
43+
type=EventType.RUN_STARTED,
44+
thread_id=thread_id,
45+
run_id=run_id,
46+
)
47+
48+
49+
def run_finished_event(thread_id: str, run_id: str) -> RunFinishedEvent:
50+
return RunFinishedEvent(
51+
type=EventType.RUN_FINISHED,
52+
thread_id=thread_id,
53+
run_id=run_id,
54+
)
55+
56+
57+
def run_error_event(message: str) -> RunErrorEvent:
58+
return RunErrorEvent(
59+
type=EventType.RUN_ERROR,
60+
message=message,
61+
)
62+
63+
64+
def text_message_start(message_id: str, role: str = "assistant") -> TextMessageStartEvent:
65+
return TextMessageStartEvent(
66+
type=EventType.TEXT_MESSAGE_START,
67+
message_id=message_id,
68+
role=role,
69+
)
70+
71+
72+
def text_message_content(message_id: str, delta: str) -> TextMessageContentEvent:
73+
return TextMessageContentEvent(
74+
type=EventType.TEXT_MESSAGE_CONTENT,
75+
message_id=message_id,
76+
delta=delta,
77+
)
78+
79+
80+
def text_message_end(message_id: str) -> TextMessageEndEvent:
81+
return TextMessageEndEvent(
82+
type=EventType.TEXT_MESSAGE_END,
83+
message_id=message_id,
84+
)
85+
86+
87+
def text_message_events(content: str, message_id: Optional[str] = None) -> list:
88+
"""Convert a complete text into the three-event TEXT_MESSAGE sequence."""
89+
msg_id = message_id or str(uuid.uuid4())
90+
return [
91+
text_message_start(msg_id, role="assistant"),
92+
text_message_content(msg_id, content),
93+
text_message_end(msg_id),
94+
]
95+
96+
97+
def tool_call_start(tool_call_id: str, tool_name: str, parent_message_id: Optional[str] = None) -> ToolCallStartEvent:
98+
return ToolCallStartEvent(
99+
type=EventType.TOOL_CALL_START,
100+
tool_call_id=tool_call_id,
101+
tool_call_name=tool_name,
102+
parent_message_id=parent_message_id,
103+
)
104+
105+
106+
def tool_call_args(tool_call_id: str, args: Any) -> ToolCallArgsEvent:
107+
args_str = json.dumps(args) if not isinstance(args, str) else args
108+
return ToolCallArgsEvent(
109+
type=EventType.TOOL_CALL_ARGS,
110+
tool_call_id=tool_call_id,
111+
delta=args_str,
112+
)
113+
114+
115+
def tool_call_end(tool_call_id: str) -> ToolCallEndEvent:
116+
return ToolCallEndEvent(
117+
type=EventType.TOOL_CALL_END,
118+
tool_call_id=tool_call_id,
119+
)
120+
121+
122+
def tool_call_result(tool_call_id: str, content: str, message_id: Optional[str] = None) -> ToolCallResultEvent:
123+
msg_id = message_id or str(uuid.uuid4())
124+
return ToolCallResultEvent(
125+
type=EventType.TOOL_CALL_RESULT,
126+
message_id=msg_id,
127+
tool_call_id=tool_call_id,
128+
content=content,
129+
role="tool",
130+
)
131+
132+
133+
def tool_call_events(
134+
tool_name: str,
135+
args: Any,
136+
result: str,
137+
tool_call_id: Optional[str] = None,
138+
parent_message_id: Optional[str] = None,
139+
) -> list:
140+
"""Convert a complete tool invocation into the four-event TOOL_CALL sequence."""
141+
tc_id = tool_call_id or str(uuid.uuid4())
142+
return [
143+
tool_call_start(tc_id, tool_name, parent_message_id),
144+
tool_call_args(tc_id, args),
145+
tool_call_end(tc_id),
146+
tool_call_result(tc_id, result),
147+
]
148+
149+
150+
def step_started_event(step_name: str) -> StepStartedEvent:
151+
return StepStartedEvent(
152+
type=EventType.STEP_STARTED,
153+
step_name=step_name,
154+
)
155+
156+
157+
def step_finished_event(step_name: str) -> StepFinishedEvent:
158+
return StepFinishedEvent(
159+
type=EventType.STEP_FINISHED,
160+
step_name=step_name,
161+
)
162+
163+
164+
def state_snapshot_event(snapshot: dict[str, Any]) -> StateSnapshotEvent:
165+
return StateSnapshotEvent(
166+
type=EventType.STATE_SNAPSHOT,
167+
snapshot=snapshot,
168+
)
169+
170+
171+
def structured_output_event(
172+
output: Any,
173+
schema: Optional[dict[str, Any]] = None,
174+
) -> CustomEvent:
175+
"""Emit structured agent output as a CUSTOM event with widget metadata.
176+
177+
The frontend uses the schema's x-ui annotations to pick the right
178+
CopilotKit tool render for each field.
179+
"""
180+
return CustomEvent(
181+
type=EventType.CUSTOM,
182+
name="structured_output",
183+
value={
184+
"output": output,
185+
"schema": schema or {},
186+
},
187+
)
188+
189+
190+
# ---------------------------------------------------------------------------
191+
# SSE encoding — thin wrapper, still a pure calculation
192+
# ---------------------------------------------------------------------------
193+
194+
def encode_event(event: Any) -> str:
195+
"""Serialize an AG-UI event to an SSE data line."""
196+
return _encoder.encode(event)

backend/agent_builder/engine/callbacks.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,14 @@ def _extract_llm_call_metadata(
157157
# ---------------------------------------------------------------------------
158158

159159
def make_streaming_callback(run_id: str, event_bus: AgentEventBus) -> Any:
160-
"""Create a callback handler that publishes agent events to the SSE event bus."""
160+
"""Create a callback handler that publishes AG-UI events to the SSE event bus."""
161161
from langchain_core.callbacks import BaseCallbackHandler
162162

163163
class StreamingCallbackHandler(BaseCallbackHandler):
164164
def __init__(self) -> None:
165165
super().__init__()
166166
self._start_times: dict[Any, float] = {}
167+
self._tool_names: dict[Any, str] = {}
167168

168169
def on_tool_start(
169170
self,
@@ -175,30 +176,43 @@ def on_tool_start(
175176
) -> None:
176177
self._start_times[run_id] = perf_counter()
177178
name = serialized.get("name", "unknown")
179+
self._tool_names[run_id] = name
178180
preview = input_str[:500] if isinstance(input_str, str) else str(input_str)[:500]
179181
event_bus.publish(AgentEvent(
180182
run_id=run_id_outer,
181-
event_type="tool_start",
182-
data={"tool_name": name, "input": preview},
183+
event_type="TOOL_CALL_START",
184+
data={"toolCallId": str(run_id), "toolCallName": name, "args": preview},
183185
))
184186

185187
def on_tool_end(self, output: str, *, run_id: Any = None, **kwargs: Any) -> None:
186188
started = self._start_times.pop(run_id, None)
187189
duration_ms = int((perf_counter() - started) * 1000) if started is not None else None
190+
name = self._tool_names.pop(run_id, "")
188191
preview = output[:500] if isinstance(output, str) else str(output)[:500]
189192
event_bus.publish(AgentEvent(
190193
run_id=run_id_outer,
191-
event_type="tool_end",
192-
data={"tool_name": kwargs.get("name", ""), "output": preview, "duration_ms": duration_ms},
194+
event_type="TOOL_CALL_END",
195+
data={"toolCallId": str(run_id), "toolCallName": name},
196+
))
197+
event_bus.publish(AgentEvent(
198+
run_id=run_id_outer,
199+
event_type="TOOL_CALL_RESULT",
200+
data={
201+
"toolCallId": str(run_id),
202+
"toolCallName": name,
203+
"content": preview,
204+
"durationMs": duration_ms,
205+
},
193206
))
194207

195208
def on_tool_error(self, error: BaseException, *, run_id: Any = None, **kwargs: Any) -> None:
196209
started = self._start_times.pop(run_id, None)
197210
duration_ms = int((perf_counter() - started) * 1000) if started is not None else None
211+
name = self._tool_names.pop(run_id, "")
198212
event_bus.publish(AgentEvent(
199213
run_id=run_id_outer,
200-
event_type="tool_error",
201-
data={"error": str(error), "duration_ms": duration_ms},
214+
event_type="TOOL_CALL_END",
215+
data={"toolCallId": str(run_id), "toolCallName": name, "error": str(error), "durationMs": duration_ms},
202216
))
203217

204218
def on_llm_start(
@@ -219,8 +233,8 @@ def on_llm_start(
219233
)
220234
event_bus.publish(AgentEvent(
221235
run_id=run_id_outer,
222-
event_type="llm_start",
223-
data={"model": model_name or ""},
236+
event_type="STEP_STARTED",
237+
data={"stepName": "llm_call", "model": model_name or ""},
224238
))
225239

226240
def on_llm_end(self, response: Any, *, run_id: UUID = None, **kwargs: Any) -> None:
@@ -229,12 +243,13 @@ def on_llm_end(self, response: Any, *, run_id: UUID = None, **kwargs: Any) -> No
229243
token_usage, model_name, finish_reason = _extract_llm_call_metadata(response)
230244
event_bus.publish(AgentEvent(
231245
run_id=run_id_outer,
232-
event_type="llm_end",
246+
event_type="STEP_FINISHED",
233247
data={
248+
"stepName": "llm_call",
234249
"model": model_name or "",
235-
"duration_ms": duration_ms,
236-
"token_usage": token_usage or {},
237-
"finish_reason": finish_reason or "",
250+
"durationMs": duration_ms,
251+
"tokenUsage": token_usage or {},
252+
"finishReason": finish_reason or "",
238253
},
239254
))
240255

@@ -243,8 +258,8 @@ def on_llm_error(self, error: BaseException, *, run_id: UUID = None, **kwargs: A
243258
duration_ms = int((perf_counter() - started_at) * 1000) if started_at is not None else None
244259
event_bus.publish(AgentEvent(
245260
run_id=run_id_outer,
246-
event_type="llm_error",
247-
data={"error": str(error), "duration_ms": duration_ms},
261+
event_type="STEP_FINISHED",
262+
data={"stepName": "llm_call", "error": str(error), "durationMs": duration_ms},
248263
))
249264

250265
# Closure captures run_id as run_id_outer to avoid shadowing with LangChain's run_id param

backend/agent_builder/engine/event_bus.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
"""
22
Agent Builder — Event Bus
33
4-
Pub/sub for real-time agent activity events.
4+
Pub/sub for real-time agent activity events using AG-UI protocol format.
55
Subscribers receive events via asyncio.Queue; a history buffer
66
provides catch-up for new SSE connections.
77
8-
Data: AgentEvent dataclass
8+
Data: AgentEvent dataclass (carries AG-UI typed event dicts)
99
Action: publish / subscribe / unsubscribe (I/O via queues)
1010
"""
1111

1212
import asyncio
1313
import logging
1414
import time
15-
from dataclasses import asdict, dataclass, field
15+
from dataclasses import dataclass, field
1616
from typing import Any
1717

1818
logger = logging.getLogger(__name__)
@@ -22,14 +22,20 @@
2222

2323
@dataclass
2424
class AgentEvent:
25-
"""A single event from an agent run."""
25+
"""A single AG-UI event from an agent run."""
2626
run_id: str
2727
event_type: str
2828
data: dict[str, Any] = field(default_factory=dict)
2929
timestamp: float = field(default_factory=time.time)
3030

3131
def to_sse_dict(self) -> dict[str, Any]:
32-
return asdict(self)
32+
"""Return the AG-UI event dict for SSE serialization."""
33+
return {
34+
"type": self.event_type,
35+
"runId": self.run_id,
36+
"timestamp": int(self.timestamp * 1000),
37+
**self.data,
38+
}
3339

3440

3541
class AgentEventBus:

backend/agent_builder/models/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
SuccessCriteria,
1818
)
1919
from .run import AgentRun, AgentRunCreate, RunStatus
20+
from .thread import (
21+
ConversationThread,
22+
MessageRole,
23+
ThreadCreate,
24+
ThreadMessage,
25+
ThreadMessageCreate,
26+
ThreadStatus,
27+
)
2028

2129
__all__ = [
2230
"AgentDefinition",
@@ -27,8 +35,14 @@
2735
"AgentResponse",
2836
"AgentRun",
2937
"AgentRunCreate",
38+
"ConversationThread",
3039
"CriteriaResult",
3140
"CriteriaType",
41+
"MessageRole",
3242
"RunStatus",
3343
"SuccessCriteria",
44+
"ThreadCreate",
45+
"ThreadMessage",
46+
"ThreadMessageCreate",
47+
"ThreadStatus",
3448
]

0 commit comments

Comments
 (0)