reflex-agentkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. reflex_agentkit/__init__.py +68 -0
  2. reflex_agentkit/adapters/__init__.py +1 -0
  3. reflex_agentkit/adapters/_require.py +27 -0
  4. reflex_agentkit/adapters/langgraph.py +277 -0
  5. reflex_agentkit/adapters/pydantic_ai.py +93 -0
  6. reflex_agentkit/components/__init__.py +21 -0
  7. reflex_agentkit/components/approval.py +123 -0
  8. reflex_agentkit/components/chat.py +42 -0
  9. reflex_agentkit/components/composer.py +44 -0
  10. reflex_agentkit/components/messages.py +104 -0
  11. reflex_agentkit/components/shared_state.py +32 -0
  12. reflex_agentkit/components/status.py +41 -0
  13. reflex_agentkit/components/tools.py +97 -0
  14. reflex_agentkit/core/__init__.py +56 -0
  15. reflex_agentkit/core/_log.py +47 -0
  16. reflex_agentkit/core/_patch.py +46 -0
  17. reflex_agentkit/core/clients/__init__.py +14 -0
  18. reflex_agentkit/core/clients/base.py +71 -0
  19. reflex_agentkit/core/clients/http.py +147 -0
  20. reflex_agentkit/core/clients/in_process.py +67 -0
  21. reflex_agentkit/core/clients/sse.py +75 -0
  22. reflex_agentkit/core/commands.py +589 -0
  23. reflex_agentkit/core/conversation.py +93 -0
  24. reflex_agentkit/core/errors.py +74 -0
  25. reflex_agentkit/core/models.py +243 -0
  26. reflex_agentkit/core/reducer.py +1078 -0
  27. reflex_agentkit/core/run_input.py +122 -0
  28. reflex_agentkit/core/tools.py +204 -0
  29. reflex_agentkit/py.typed +0 -0
  30. reflex_agentkit/state/__init__.py +5 -0
  31. reflex_agentkit/state/_driver.py +520 -0
  32. reflex_agentkit/state/_projection.py +120 -0
  33. reflex_agentkit/state/_registry.py +46 -0
  34. reflex_agentkit/state/agent_state.py +496 -0
  35. reflex_agentkit-0.1.0.dist-info/METADATA +457 -0
  36. reflex_agentkit-0.1.0.dist-info/RECORD +38 -0
  37. reflex_agentkit-0.1.0.dist-info/WHEEL +4 -0
  38. reflex_agentkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1078 @@
1
+ """The pure AG-UI event reducer: ``reduce(state, event) -> state`` (API §11, REQ-005).
2
+
3
+ ``reduce`` is the only path by which agent events change a conversation. It does no
4
+ I/O, reads no clock and uses no randomness. It is total: an event it cannot apply
5
+ becomes a :class:`Diagnostic` on the returned state instead of an exception. The state
6
+ layer logs those diagnostics and clears them.
7
+
8
+ Dispatch is a table keyed by ``EventType``. Types without a handler (a future protocol
9
+ addition) fall through to an ``UNKNOWN_EVENT_TYPE`` debug diagnostic (REQ-012.2), so a
10
+ new upstream type never needs a code change to stay safe.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import copy
16
+ import dataclasses
17
+ import json
18
+ from collections.abc import Callable, Mapping
19
+ from typing import Any
20
+
21
+ from ag_ui.core import (
22
+ BaseEvent,
23
+ CustomEvent,
24
+ Event,
25
+ EventType,
26
+ MessagesSnapshotEvent,
27
+ ReasoningEncryptedValueEvent,
28
+ ReasoningMessageChunkEvent,
29
+ ReasoningMessageContentEvent,
30
+ ReasoningMessageEndEvent,
31
+ ReasoningMessageStartEvent,
32
+ RunErrorEvent,
33
+ RunFinishedEvent,
34
+ RunStartedEvent,
35
+ StateDeltaEvent,
36
+ StateSnapshotEvent,
37
+ StepFinishedEvent,
38
+ StepStartedEvent,
39
+ TextMessageChunkEvent,
40
+ TextMessageContentEvent,
41
+ TextMessageEndEvent,
42
+ TextMessageStartEvent,
43
+ ToolCallArgsEvent,
44
+ ToolCallChunkEvent,
45
+ ToolCallEndEvent,
46
+ ToolCallResultEvent,
47
+ ToolCallStartEvent,
48
+ )
49
+ from pydantic import TypeAdapter, ValidationError
50
+
51
+ from reflex_agentkit.core._patch import PatchError, apply_patch
52
+ from reflex_agentkit.core.conversation import ConversationState
53
+ from reflex_agentkit.core.errors import ErrorCode
54
+ from reflex_agentkit.core.models import (
55
+ ChatMessage,
56
+ CustomEventRecord,
57
+ Diagnostic,
58
+ DiagnosticCode,
59
+ DiagnosticLevel,
60
+ JsonObject,
61
+ MessageRole,
62
+ OpenInterrupt,
63
+ ReasoningRecord,
64
+ RunStatus,
65
+ ToolCall,
66
+ ToolCallStatus,
67
+ )
68
+
69
+ __all__ = [
70
+ "HANDLED_EVENT_TYPES",
71
+ "INCOMPLETE_TOOL_CALL",
72
+ "INVALID_TOOL_ARGUMENTS",
73
+ "close_streams",
74
+ "parse_event",
75
+ "reduce",
76
+ ]
77
+
78
+ Handler = Callable[[ConversationState, Any], ConversationState]
79
+
80
+ _EVENT_ADAPTER: TypeAdapter[Any] = TypeAdapter(Event)
81
+ _KNOWN_TYPES = frozenset(member.value for member in EventType)
82
+
83
+ INCOMPLETE_TOOL_CALL = "The run ended before the tool call's arguments were complete."
84
+ INVALID_TOOL_ARGUMENTS = "Invalid tool arguments: the arguments are not a valid JSON object."
85
+
86
+ # REQ-012.1: accepted, never rendered in v0.1.
87
+ _IGNORED: frozenset[str] = frozenset(
88
+ {
89
+ EventType.ACTIVITY_SNAPSHOT,
90
+ EventType.ACTIVITY_DELTA,
91
+ EventType.SUBAGENT_STARTED,
92
+ EventType.SUBAGENT_FINISHED,
93
+ EventType.SUBAGENT_ERROR,
94
+ EventType.RAW,
95
+ }
96
+ )
97
+
98
+
99
+ # --- helpers -------------------------------------------------------------------------------
100
+
101
+
102
+ def _type_name(event_type: object) -> str:
103
+ return str(getattr(event_type, "value", event_type))
104
+
105
+
106
+ def _diag(
107
+ conv: ConversationState,
108
+ level: DiagnosticLevel,
109
+ code: DiagnosticCode,
110
+ message: str,
111
+ event_type: str | None,
112
+ ) -> ConversationState:
113
+ diagnostic = Diagnostic(level=level, code=code, message=message, event_type=event_type)
114
+ return conv.with_(diagnostics=(*conv.diagnostics, diagnostic))
115
+
116
+
117
+ def close_streams(conv: ConversationState, *, finished: bool) -> ConversationState:
118
+ """End every open stream when the run ends (REQ-008.6, REQ-009.5, INV-5).
119
+
120
+ On ``RUN_FINISHED`` a call still streaming gets its implicit END (chunk-fed calls
121
+ have no explicit one). On ``RUN_ERROR`` it fails as incomplete.
122
+ """
123
+ changes: dict[str, Any] = {}
124
+ if any(m.streaming for m in conv.messages):
125
+ changes["messages"] = tuple(
126
+ dataclasses.replace(m, streaming=False) if m.streaming else m for m in conv.messages
127
+ )
128
+ if any(c.status is ToolCallStatus.STREAMING for c in conv.tool_calls):
129
+ changes["tool_calls"] = tuple(
130
+ (
131
+ _end_call(c)
132
+ if finished
133
+ else dataclasses.replace(
134
+ c, status=ToolCallStatus.ERROR, result=INCOMPLETE_TOOL_CALL
135
+ )
136
+ )
137
+ if c.status is ToolCallStatus.STREAMING
138
+ else c
139
+ for c in conv.tool_calls
140
+ )
141
+ changes["dirty_tool_args"] = frozenset()
142
+ if any(r.assistant_message_id is None for r in conv.reasoning):
143
+ # DM §3.6: reasoning with no following assistant message joins the previous one.
144
+ last = _last_assistant_id(conv.messages)
145
+ if last is not None:
146
+ changes["reasoning"] = _link_reasoning(conv.reasoning, last)
147
+ return conv.with_(**changes) if changes else conv
148
+
149
+
150
+ def _last_assistant_id(messages: tuple[ChatMessage, ...]) -> str | None:
151
+ for msg in reversed(messages):
152
+ if msg.role is MessageRole.ASSISTANT:
153
+ return msg.id
154
+ return None
155
+
156
+
157
+ def _link_reasoning(
158
+ records: tuple[ReasoningRecord, ...], assistant_id: str
159
+ ) -> tuple[ReasoningRecord, ...]:
160
+ """Attach every not-yet-linked reasoning record to ``assistant_id`` (DM §3.6)."""
161
+ return tuple(
162
+ dataclasses.replace(r, assistant_message_id=assistant_id)
163
+ if r.assistant_message_id is None
164
+ else r
165
+ for r in records
166
+ )
167
+
168
+
169
+ def _stale(conv: ConversationState, run_id: str, event_type: str) -> ConversationState:
170
+ return _diag(
171
+ conv,
172
+ "warning",
173
+ DiagnosticCode.STALE_RUN_EVENT,
174
+ f"discarded {event_type} of run {run_id!r}, which is not part of the current turn",
175
+ event_type,
176
+ )
177
+
178
+
179
+ def _after_end(conv: ConversationState, event_type: str) -> ConversationState:
180
+ return _diag(
181
+ conv,
182
+ "debug",
183
+ DiagnosticCode.IGNORED_EVENT,
184
+ f"ignored {event_type}: the run is not running (status {conv.run_status.value!r})",
185
+ event_type,
186
+ )
187
+
188
+
189
+ # --- lifecycle -----------------------------------------------------------------------------
190
+
191
+
192
+ def _run_started(conv: ConversationState, event: RunStartedEvent) -> ConversationState:
193
+ """REQ-010.1-2: accept runs of the current turn; discard any other run.
194
+
195
+ ``begin_run`` already recorded the active ``run_id``. A late ``RUN_STARTED`` of an
196
+ earlier run of the same turn must not move it back: the driver's ownership check
197
+ compares ``run_id`` with the run it is streaming (TD Flow A.7).
198
+ """
199
+ if event.run_id not in conv.last_run_ids:
200
+ return _stale(conv, event.run_id, EventType.RUN_STARTED.value)
201
+ return conv
202
+
203
+
204
+ def _interrupt_payload(metadata: object) -> Any:
205
+ """DM §3.4: LangGraph carries the ``interrupt(value)`` argument in its metadata."""
206
+ if isinstance(metadata, dict):
207
+ langgraph = metadata.get("langgraph")
208
+ if isinstance(langgraph, dict):
209
+ return langgraph.get("raw")
210
+ return None
211
+
212
+
213
+ def _pause(conv: ConversationState, interrupts: list[Any]) -> ConversationState:
214
+ """REQ-004.2, REQ-013.1, REQ-014.1/3: store the interrupts in arrival order.
215
+
216
+ An interrupt is an approval exactly when it names a known tool call; that call goes
217
+ to ``pending_approval``. A ``tool_call`` interrupt naming an unknown call is kept as a
218
+ generic one (``tool_call_id=None``) so it can still be answered (DM §7.2 v1.5).
219
+ """
220
+ event_type = EventType.RUN_FINISHED.value
221
+ opened: list[OpenInterrupt] = []
222
+ for interrupt in interrupts:
223
+ if any(o.id == interrupt.id for o in opened):
224
+ conv = _diag(
225
+ conv,
226
+ "warning",
227
+ DiagnosticCode.DUPLICATE_ID,
228
+ f"dropped repeated interrupt {interrupt.id!r}",
229
+ event_type,
230
+ )
231
+ continue
232
+ call_id = interrupt.tool_call_id
233
+ if call_id is not None and call_id not in conv._tool_index:
234
+ conv = _diag(
235
+ conv,
236
+ "warning",
237
+ DiagnosticCode.UNKNOWN_TOOL_CALL_ID,
238
+ f"interrupt {interrupt.id!r} names unknown tool call {call_id!r};"
239
+ " it is presented as a generic interrupt",
240
+ event_type,
241
+ )
242
+ call_id = None
243
+ opened.append(
244
+ OpenInterrupt(
245
+ id=interrupt.id,
246
+ reason=interrupt.reason or "",
247
+ message=interrupt.message or "",
248
+ tool_call_id=call_id,
249
+ payload=_interrupt_payload(interrupt.metadata),
250
+ response_schema=interrupt.response_schema,
251
+ )
252
+ )
253
+ if call_id is not None:
254
+ call = conv.tool_calls[conv._tool_index[call_id]]
255
+ pending = dataclasses.replace(
256
+ call, status=ToolCallStatus.PENDING_APPROVAL, interrupt_id=interrupt.id
257
+ )
258
+ conv = _replace_call(conv, pending)
259
+ if not opened:
260
+ return conv.with_(run_status=RunStatus.IDLE)
261
+ return conv.with_(interrupts=tuple(opened), answers={}, run_status=RunStatus.AWAITING_INPUT)
262
+
263
+
264
+ def _awaits_client_tools(conv: ConversationState, pending_ids: list[str] | None) -> bool:
265
+ """REQ-004.1, A-18: client-tool calls handed off at END, or listed as pending."""
266
+ if any(c.is_client_tool and c.status is ToolCallStatus.RUNNING for c in conv.tool_calls):
267
+ return True
268
+ return any(call_id in conv._tool_index for call_id in pending_ids or ())
269
+
270
+
271
+ def _run_finished(conv: ConversationState, event: RunFinishedEvent) -> ConversationState:
272
+ """REQ-004.1-2, API §8.3: end the run according to its outcome."""
273
+ if event.run_id not in conv.last_run_ids:
274
+ return _stale(conv, event.run_id, EventType.RUN_FINISHED.value)
275
+ outcome = event.outcome
276
+ outcome_type = getattr(outcome, "type", "success")
277
+ if outcome_type == "cancelled":
278
+ return close_streams(conv, finished=False).with_(run_status=RunStatus.CANCELLED)
279
+ conv = close_streams(conv, finished=True)
280
+ if outcome_type == "interrupt":
281
+ return _pause(conv, list(getattr(outcome, "interrupts", None) or []))
282
+ if _awaits_client_tools(conv, getattr(outcome, "pending_tool_call_ids", None)):
283
+ return conv # the client-tool round keeps the run running (REQ-018.2)
284
+ return conv.with_(run_status=RunStatus.IDLE)
285
+
286
+
287
+ def _run_error(conv: ConversationState, event: RunErrorEvent) -> ConversationState:
288
+ """REQ-004.3: the run failed; the content streamed so far is kept."""
289
+ return close_streams(conv, finished=False).with_(
290
+ run_status=RunStatus.ERROR, error=event.message, error_code=ErrorCode.AGENT_RUN_ERROR
291
+ )
292
+
293
+
294
+ def _step_started(conv: ConversationState, event: StepStartedEvent) -> ConversationState:
295
+ """REQ-010.3: expose the step name."""
296
+ return conv.with_(current_step=event.step_name)
297
+
298
+
299
+ def _step_finished(conv: ConversationState, event: StepFinishedEvent) -> ConversationState:
300
+ """REQ-010.3: clear ``current_step`` when *that* step finishes."""
301
+ if conv.current_step != event.step_name:
302
+ return conv
303
+ return conv.with_(current_step="")
304
+
305
+
306
+ # --- text messages -------------------------------------------------------------------------
307
+
308
+
309
+ def _replace_message(conv: ConversationState, index: int, msg: ChatMessage) -> ConversationState:
310
+ return conv.with_(messages=(*conv.messages[:index], msg, *conv.messages[index + 1 :]))
311
+
312
+
313
+ def _append_message(conv: ConversationState, msg: ChatMessage) -> ConversationState:
314
+ """Append a message; a new assistant message owns the turn's pending reasoning."""
315
+ if msg.role is MessageRole.ASSISTANT and any(
316
+ r.assistant_message_id is None for r in conv.reasoning
317
+ ):
318
+ return conv.with_(
319
+ messages=(*conv.messages, msg), reasoning=_link_reasoning(conv.reasoning, msg.id)
320
+ )
321
+ return conv.with_(messages=(*conv.messages, msg))
322
+
323
+
324
+ def _role(role: str | None) -> MessageRole:
325
+ return MessageRole(role) if role else MessageRole.ASSISTANT
326
+
327
+
328
+ def _append_delta(conv: ConversationState, index: int, delta: str) -> ConversationState:
329
+ msg = conv.messages[index]
330
+ return _replace_message(conv, index, dataclasses.replace(msg, content=msg.content + delta))
331
+
332
+
333
+ def _text_start(conv: ConversationState, event: TextMessageStartEvent) -> ConversationState:
334
+ """REQ-008.1: append a streaming message (assistant unless the event names a role)."""
335
+ if event.message_id in conv._message_index:
336
+ return _diag(
337
+ conv,
338
+ "warning",
339
+ DiagnosticCode.DUPLICATE_ID,
340
+ f"ignored a second start of message {event.message_id!r}",
341
+ EventType.TEXT_MESSAGE_START.value,
342
+ )
343
+ msg = ChatMessage(id=event.message_id, role=_role(event.role), streaming=True)
344
+ return _append_message(conv, msg)
345
+
346
+
347
+ def _text_content(conv: ConversationState, event: TextMessageContentEvent) -> ConversationState:
348
+ """REQ-008.2, REQ-008.5: append the delta, creating the message if it is unknown."""
349
+ index = conv._message_index.get(event.message_id)
350
+ if index is not None:
351
+ return _append_delta(conv, index, event.delta)
352
+ msg = ChatMessage(
353
+ id=event.message_id, role=MessageRole.ASSISTANT, content=event.delta, streaming=True
354
+ )
355
+ return _diag(
356
+ _append_message(conv, msg),
357
+ "warning",
358
+ DiagnosticCode.IMPLICIT_MESSAGE,
359
+ f"created message {event.message_id!r} implicitly: content arrived before its start",
360
+ EventType.TEXT_MESSAGE_CONTENT.value,
361
+ )
362
+
363
+
364
+ def _text_end(conv: ConversationState, event: TextMessageEndEvent) -> ConversationState:
365
+ """REQ-008.3: stop streaming the message."""
366
+ index = conv._message_index.get(event.message_id)
367
+ if index is None:
368
+ return _diag(
369
+ conv,
370
+ "warning",
371
+ DiagnosticCode.UNKNOWN_MESSAGE_ID,
372
+ f"ignored the end of unknown message {event.message_id!r}",
373
+ EventType.TEXT_MESSAGE_END.value,
374
+ )
375
+ msg = conv.messages[index]
376
+ if not msg.streaming:
377
+ return conv
378
+ return _replace_message(conv, index, dataclasses.replace(msg, streaming=False))
379
+
380
+
381
+ def _open_message_index(conv: ConversationState) -> int | None:
382
+ """The message a chunk without an ID continues: the last one still streaming."""
383
+ for index in range(len(conv.messages) - 1, -1, -1):
384
+ if conv.messages[index].streaming:
385
+ return index
386
+ return None
387
+
388
+
389
+ def _text_chunk(conv: ConversationState, event: TextMessageChunkEvent) -> ConversationState:
390
+ """REQ-008.4: chunks are START (first chunk of an ID) + CONTENT; END comes at run end.
391
+
392
+ A chunk that starts a new message ends the message chunks were feeding until then,
393
+ as the AG-UI chunk semantics require. A chunk without an ID continues that message.
394
+ """
395
+ delta = event.delta or ""
396
+ if event.message_id is None:
397
+ index = _open_message_index(conv)
398
+ if index is None:
399
+ return _diag(
400
+ conv,
401
+ "warning",
402
+ DiagnosticCode.UNKNOWN_MESSAGE_ID,
403
+ "ignored a text chunk without a message ID and no message to continue",
404
+ EventType.TEXT_MESSAGE_CHUNK.value,
405
+ )
406
+ return _append_delta(conv, index, delta) if delta else conv
407
+
408
+ index = conv._message_index.get(event.message_id)
409
+ if index is not None:
410
+ return _append_delta(conv, index, delta) if delta else conv
411
+
412
+ previous = _open_message_index(conv)
413
+ if previous is not None:
414
+ closed = dataclasses.replace(conv.messages[previous], streaming=False)
415
+ conv = _replace_message(conv, previous, closed)
416
+ msg = ChatMessage(id=event.message_id, role=_role(event.role), content=delta, streaming=True)
417
+ return _append_message(conv, msg)
418
+
419
+
420
+ # --- tool calls ----------------------------------------------------------------------------
421
+
422
+
423
+ def _reject_constant(name: str) -> object:
424
+ """``NaN``/``Infinity`` are not JSON: re-sending them upstream would be invalid."""
425
+ raise ValueError(f"non-standard JSON constant {name}")
426
+
427
+
428
+ def _parse_args(text: str) -> JsonObject | None:
429
+ """Parse accumulated args; an empty text is ``{}``. Non-objects are invalid."""
430
+ if not text.strip():
431
+ return {}
432
+ try:
433
+ value = json.loads(text, parse_constant=_reject_constant)
434
+ except (ValueError, RecursionError):
435
+ # RecursionError: pathologically nested input must not break totality (REQ-005).
436
+ return None
437
+ return value if isinstance(value, dict) else None
438
+
439
+
440
+ def _end_call(call: ToolCall) -> ToolCall:
441
+ """REQ-009.3, REQ-009.7: the args are complete; parse them and hand the call over."""
442
+ args = _parse_args(call.args_text)
443
+ if args is None:
444
+ return dataclasses.replace(call, status=ToolCallStatus.ERROR, result=INVALID_TOOL_ARGUMENTS)
445
+ return dataclasses.replace(call, args=args, status=ToolCallStatus.RUNNING)
446
+
447
+
448
+ def _replace_call(conv: ConversationState, call: ToolCall, **changes: Any) -> ConversationState:
449
+ index = conv._tool_index[call.id]
450
+ calls = (*conv.tool_calls[:index], call, *conv.tool_calls[index + 1 :])
451
+ return conv.with_(tool_calls=calls, **changes)
452
+
453
+
454
+ def _unknown_call(
455
+ conv: ConversationState, call_id: str | None, event_type: str
456
+ ) -> ConversationState:
457
+ return _diag(
458
+ conv,
459
+ "warning",
460
+ DiagnosticCode.UNKNOWN_TOOL_CALL_ID,
461
+ f"ignored {event_type} for unknown tool call {call_id!r}",
462
+ event_type,
463
+ )
464
+
465
+
466
+ def _link_parent(
467
+ conv: ConversationState, call_id: str, parent_id: str | None
468
+ ) -> tuple[ConversationState, str]:
469
+ """Attach the call to its assistant message, creating the message if needed.
470
+
471
+ Egress (DM §7.3) re-sends a call inside its assistant message, so every call needs
472
+ one. Without ``parentMessageId`` the call joins the last message when it is the
473
+ assistant's; otherwise a message with the deterministic ID ``assistant-<call_id>``
474
+ is created (the reducer must not generate random IDs).
475
+ """
476
+ if parent_id is None:
477
+ last = conv.messages[-1] if conv.messages else None
478
+ parent_id = (
479
+ last.id if last is not None and last.role is MessageRole.ASSISTANT else None
480
+ ) or f"assistant-{call_id}"
481
+ index = conv._message_index.get(parent_id)
482
+ if index is None:
483
+ parent = ChatMessage(id=parent_id, role=MessageRole.ASSISTANT, tool_call_ids=(call_id,))
484
+ return _append_message(conv, parent), parent_id
485
+ msg = conv.messages[index]
486
+ linked = dataclasses.replace(msg, tool_call_ids=(*msg.tool_call_ids, call_id))
487
+ return _replace_message(conv, index, linked), parent_id
488
+
489
+
490
+ def _start_call(
491
+ conv: ConversationState, call_id: str, name: str, parent_id: str | None, event_type: str
492
+ ) -> ConversationState:
493
+ if call_id in conv._tool_index:
494
+ return _diag(
495
+ conv,
496
+ "warning",
497
+ DiagnosticCode.DUPLICATE_ID,
498
+ f"ignored a second start of tool call {call_id!r}",
499
+ event_type,
500
+ )
501
+ conv, parent_id = _link_parent(conv, call_id, parent_id)
502
+ call = ToolCall(
503
+ id=call_id,
504
+ name=name,
505
+ parent_message_id=parent_id,
506
+ is_client_tool=name in conv.config.client_tool_names,
507
+ )
508
+ return conv.with_(tool_calls=(*conv.tool_calls, call))
509
+
510
+
511
+ def _too_large(text: str, limit: int) -> bool:
512
+ # A UTF-8 character is at most 4 bytes: skip encoding while clearly under the cap.
513
+ return len(text) * 4 > limit and len(text.encode("utf-8")) > limit
514
+
515
+
516
+ def _append_args(
517
+ conv: ConversationState, call: ToolCall, delta: str, event_type: str
518
+ ) -> ConversationState:
519
+ if call.status is not ToolCallStatus.STREAMING or not delta:
520
+ return conv
521
+ text = call.args_text + delta
522
+ limit = conv.config.max_args_bytes
523
+ if _too_large(text, limit):
524
+ failed = dataclasses.replace(
525
+ call,
526
+ args_text="",
527
+ status=ToolCallStatus.ERROR,
528
+ result=f"Invalid tool arguments: the arguments exceed {limit} bytes.",
529
+ )
530
+ conv = _replace_call(conv, failed, dirty_tool_args=conv.dirty_tool_args - {call.id})
531
+ return _diag(
532
+ conv,
533
+ "warning",
534
+ DiagnosticCode.ARGS_TOO_LARGE,
535
+ f"tool call {call.id!r} arguments exceed {limit} bytes",
536
+ event_type,
537
+ )
538
+ return _replace_call(
539
+ conv,
540
+ dataclasses.replace(call, args_text=text),
541
+ dirty_tool_args=conv.dirty_tool_args | {call.id},
542
+ )
543
+
544
+
545
+ def _finish_call(conv: ConversationState, call: ToolCall) -> ConversationState:
546
+ if call.status is not ToolCallStatus.STREAMING:
547
+ return conv
548
+ return _replace_call(conv, _end_call(call), dirty_tool_args=conv.dirty_tool_args - {call.id})
549
+
550
+
551
+ def _get_call(conv: ConversationState, call_id: str) -> ToolCall | None:
552
+ index = conv._tool_index.get(call_id)
553
+ return None if index is None else conv.tool_calls[index]
554
+
555
+
556
+ def _tool_start(conv: ConversationState, event: ToolCallStartEvent) -> ConversationState:
557
+ """REQ-009.1, REQ-018.2: add a streaming call, link its parent, mark client tools."""
558
+ return _start_call(
559
+ conv,
560
+ event.tool_call_id,
561
+ event.tool_call_name,
562
+ event.parent_message_id,
563
+ EventType.TOOL_CALL_START.value,
564
+ )
565
+
566
+
567
+ def _tool_args(conv: ConversationState, event: ToolCallArgsEvent) -> ConversationState:
568
+ """REQ-009.2: accumulate args; parsing is deferred (``dirty_tool_args``, DM H-3)."""
569
+ event_type = EventType.TOOL_CALL_ARGS.value
570
+ call = _get_call(conv, event.tool_call_id)
571
+ if call is None:
572
+ return _unknown_call(conv, event.tool_call_id, event_type)
573
+ return _append_args(conv, call, event.delta, event_type)
574
+
575
+
576
+ def _tool_end(conv: ConversationState, event: ToolCallEndEvent) -> ConversationState:
577
+ """REQ-009.3, REQ-009.7: parse the args; ``running`` or ``error``."""
578
+ call = _get_call(conv, event.tool_call_id)
579
+ if call is None:
580
+ return _unknown_call(conv, event.tool_call_id, EventType.TOOL_CALL_END.value)
581
+ return _finish_call(conv, call)
582
+
583
+
584
+ def _open_call(conv: ConversationState) -> ToolCall | None:
585
+ """The call an ID-less chunk continues: the last one still streaming."""
586
+ for call in reversed(conv.tool_calls):
587
+ if call.status is ToolCallStatus.STREAMING:
588
+ return call
589
+ return None
590
+
591
+
592
+ def _tool_chunk(conv: ConversationState, event: ToolCallChunkEvent) -> ConversationState:
593
+ """REQ-009.5: chunks are START (new ID) + ARGS; a new chunk call ENDs the previous one."""
594
+ event_type = EventType.TOOL_CALL_CHUNK.value
595
+ delta = event.delta or ""
596
+ call_id = event.tool_call_id
597
+ call = _get_call(conv, call_id) if call_id is not None else _open_call(conv)
598
+ if call is not None:
599
+ return _append_args(conv, call, delta, event_type)
600
+ if call_id is None or not event.tool_call_name:
601
+ return _unknown_call(conv, call_id, event_type)
602
+ previous = _open_call(conv)
603
+ if previous is not None:
604
+ conv = _finish_call(conv, previous)
605
+ conv = _start_call(conv, call_id, event.tool_call_name, event.parent_message_id, event_type)
606
+ return _append_args(conv, conv.tool_calls[-1], delta, event_type)
607
+
608
+
609
+ def _content_text(content: str | list[Any]) -> str:
610
+ """Flatten AG-UI 1.0 multimodal tool-result content to text (DM §7.1 v1.3).
611
+
612
+ Text parts are concatenated; other parts become ``[<type>]`` placeholders, because
613
+ v0.1 renders and re-sends tool results as text only.
614
+ """
615
+ if isinstance(content, str):
616
+ return content
617
+ pieces: list[str] = []
618
+ for part in content:
619
+ text = getattr(part, "text", None)
620
+ pieces.append(text if isinstance(text, str) else f"[{getattr(part, 'type', 'content')}]")
621
+ return "".join(pieces)
622
+
623
+
624
+ def _tool_result(conv: ConversationState, event: ToolCallResultEvent) -> ConversationState:
625
+ """REQ-009.4: store the result, ``done``, and append the ``tool`` message once."""
626
+ call = _get_call(conv, event.tool_call_id)
627
+ if call is None:
628
+ return _unknown_call(conv, event.tool_call_id, EventType.TOOL_CALL_RESULT.value)
629
+ content = _content_text(event.content)
630
+ conv = _finish_call(conv, call)
631
+ call = conv.tool_calls[conv._tool_index[call.id]]
632
+ # ``rejected`` and ``error`` are terminal (API §4.6, DM v1.9): the result is recorded,
633
+ # the status stays. PydanticAI answers a rejected approval with the denial text.
634
+ terminal = call.status in (ToolCallStatus.REJECTED, ToolCallStatus.ERROR)
635
+ status = call.status if terminal else ToolCallStatus.DONE
636
+ conv = _replace_call(conv, dataclasses.replace(call, status=status, result=content))
637
+ if event.message_id in conv._message_index:
638
+ return conv
639
+ tool_message = ChatMessage(
640
+ id=event.message_id,
641
+ role=MessageRole.TOOL,
642
+ content=content,
643
+ tool_call_id=call.id,
644
+ )
645
+ return _append_message(conv, tool_message)
646
+
647
+
648
+ # --- shared state --------------------------------------------------------------------------
649
+
650
+
651
+ def _state_snapshot(conv: ConversationState, event: StateSnapshotEvent) -> ConversationState:
652
+ """REQ-016.1, REQ-016.4: replace ``shared_state`` and clear ``shared_state_stale``."""
653
+ snapshot: object = event.snapshot
654
+ if isinstance(snapshot, dict):
655
+ try:
656
+ copied: JsonObject = copy.deepcopy(snapshot)
657
+ except RecursionError:
658
+ snapshot = None # pathologically deep: treated as invalid below (REQ-005.1)
659
+ else:
660
+ return conv.with_(shared_state=copied, shared_state_stale=False)
661
+ conv = conv.with_(shared_state_stale=True, error_code=ErrorCode.SHARED_STATE_INVALID)
662
+ return _diag(
663
+ conv,
664
+ "warning",
665
+ DiagnosticCode.INVALID_EVENT,
666
+ "ignored a STATE_SNAPSHOT that is not a JSON object; shared state is stale",
667
+ EventType.STATE_SNAPSHOT.value,
668
+ )
669
+
670
+
671
+ def _state_delta(conv: ConversationState, event: StateDeltaEvent) -> ConversationState:
672
+ """REQ-016.2-3: apply RFC 6902 ops atomically; on failure keep the state, mark stale."""
673
+ try:
674
+ ops: list[JsonObject] = [
675
+ op.model_dump(mode="json", by_alias=True, exclude_unset=True) for op in event.delta
676
+ ]
677
+ state = apply_patch(conv.shared_state, ops)
678
+ except PatchError as exc:
679
+ conv = conv.with_(shared_state_stale=True, error_code=ErrorCode.SHARED_STATE_PATCH_FAILED)
680
+ return _diag(
681
+ conv, "warning", DiagnosticCode.PATCH_FAILED, str(exc), EventType.STATE_DELTA.value
682
+ )
683
+ return conv.with_(shared_state=state)
684
+
685
+
686
+ # --- CUSTOM --------------------------------------------------------------------------------
687
+
688
+
689
+ def _custom(conv: ConversationState, event: CustomEvent) -> ConversationState:
690
+ """REQ-010.5, INV-9: keep the latest ``custom_events_limit`` events."""
691
+ limit = conv.config.custom_events_limit
692
+ if limit <= 0:
693
+ return conv
694
+ record = CustomEventRecord(name=event.name, value=event.value, run_id=conv.run_id)
695
+ return conv.with_(custom_events=(*conv.custom_events, record)[-limit:])
696
+
697
+
698
+ # --- reasoning -----------------------------------------------------------------------------
699
+
700
+
701
+ def _reasoning_position(conv: ConversationState, record_id: str) -> int | None:
702
+ for index, record in enumerate(conv.reasoning):
703
+ if record.id == record_id:
704
+ return index
705
+ return None
706
+
707
+
708
+ def _replace_reasoning(
709
+ conv: ConversationState, index: int, record: ReasoningRecord
710
+ ) -> ConversationState:
711
+ return conv.with_(reasoning=(*conv.reasoning[:index], record, *conv.reasoning[index + 1 :]))
712
+
713
+
714
+ def _add_reasoning(conv: ConversationState, record_id: str, content: str) -> ConversationState:
715
+ return conv.with_(reasoning=(*conv.reasoning, ReasoningRecord(id=record_id, content=content)))
716
+
717
+
718
+ def _reasoning_delta(conv: ConversationState, index: int, delta: str) -> ConversationState:
719
+ record = conv.reasoning[index]
720
+ return _replace_reasoning(
721
+ conv, index, dataclasses.replace(record, content=record.content + delta)
722
+ )
723
+
724
+
725
+ def _reasoning_unknown(
726
+ conv: ConversationState, record_id: str | None, event_type: str
727
+ ) -> ConversationState:
728
+ return _diag(
729
+ conv,
730
+ "warning",
731
+ DiagnosticCode.UNKNOWN_MESSAGE_ID,
732
+ f"ignored {event_type} for unknown reasoning message {record_id!r}",
733
+ event_type,
734
+ )
735
+
736
+
737
+ def _reasoning_boundary(conv: ConversationState, event: BaseEvent) -> ConversationState:
738
+ """``REASONING_START``/``REASONING_END`` only frame the phase; nothing to record."""
739
+ return conv
740
+
741
+
742
+ def _reasoning_start(
743
+ conv: ConversationState, event: ReasoningMessageStartEvent
744
+ ) -> ConversationState:
745
+ """REQ-011.1: start a reasoning record; it links to the turn's next assistant message."""
746
+ if _reasoning_position(conv, event.message_id) is not None:
747
+ return _diag(
748
+ conv,
749
+ "warning",
750
+ DiagnosticCode.DUPLICATE_ID,
751
+ f"ignored a second start of reasoning message {event.message_id!r}",
752
+ EventType.REASONING_MESSAGE_START.value,
753
+ )
754
+ return _add_reasoning(conv, event.message_id, "")
755
+
756
+
757
+ def _reasoning_content(
758
+ conv: ConversationState, event: ReasoningMessageContentEvent
759
+ ) -> ConversationState:
760
+ """REQ-011.1: accumulate reasoning text (creating the record if it is unknown)."""
761
+ index = _reasoning_position(conv, event.message_id)
762
+ if index is not None:
763
+ return _reasoning_delta(conv, index, event.delta)
764
+ return _diag(
765
+ _add_reasoning(conv, event.message_id, event.delta),
766
+ "warning",
767
+ DiagnosticCode.IMPLICIT_MESSAGE,
768
+ f"created reasoning message {event.message_id!r} implicitly",
769
+ EventType.REASONING_MESSAGE_CONTENT.value,
770
+ )
771
+
772
+
773
+ def _reasoning_end(conv: ConversationState, event: ReasoningMessageEndEvent) -> ConversationState:
774
+ """REQ-011.1: records have no streaming flag; END only checks the ID."""
775
+ if _reasoning_position(conv, event.message_id) is None:
776
+ return _reasoning_unknown(conv, event.message_id, EventType.REASONING_MESSAGE_END.value)
777
+ return conv
778
+
779
+
780
+ def _reasoning_chunk(
781
+ conv: ConversationState, event: ReasoningMessageChunkEvent
782
+ ) -> ConversationState:
783
+ """REQ-011.1: a new ID starts a record; an ID-less chunk continues the last one."""
784
+ delta = event.delta or ""
785
+ if event.message_id is None:
786
+ if not conv.reasoning:
787
+ return _reasoning_unknown(conv, None, EventType.REASONING_MESSAGE_CHUNK.value)
788
+ return _reasoning_delta(conv, len(conv.reasoning) - 1, delta) if delta else conv
789
+ index = _reasoning_position(conv, event.message_id)
790
+ if index is None:
791
+ return _add_reasoning(conv, event.message_id, delta)
792
+ return _reasoning_delta(conv, index, delta) if delta else conv
793
+
794
+
795
+ def _reasoning_encrypted(
796
+ conv: ConversationState, event: ReasoningEncryptedValueEvent
797
+ ) -> ConversationState:
798
+ """REQ-011.2: keep the opaque value server-side on its reasoning record (Art. 3).
799
+
800
+ Values for tool calls or for non-reasoning messages are not retained in v0.1
801
+ (DM §7.1 v1.4).
802
+ """
803
+ index = _reasoning_position(conv, event.entity_id) if event.subtype == "message" else None
804
+ if index is None:
805
+ return _diag(
806
+ conv,
807
+ "debug",
808
+ DiagnosticCode.IGNORED_EVENT,
809
+ f"encrypted value for {event.subtype} {event.entity_id!r} is not retained in v0.1",
810
+ EventType.REASONING_ENCRYPTED_VALUE.value,
811
+ )
812
+ record = dataclasses.replace(conv.reasoning[index], encrypted_value=event.encrypted_value)
813
+ return _replace_reasoning(conv, index, record)
814
+
815
+
816
+ # --- MESSAGES_SNAPSHOT ---------------------------------------------------------------------
817
+
818
+
819
+ def _with_snapshot_args(call: ToolCall, arguments: str) -> ToolCall:
820
+ """``call`` with the snapshot's arguments if it streamed none and they are an object."""
821
+ if call.args_text.strip() or call.args or not arguments.strip():
822
+ return call # streamed or edited arguments win
823
+ args = _parse_args(arguments)
824
+ if args is None:
825
+ return call
826
+ return dataclasses.replace(call, args_text=arguments, args=args)
827
+
828
+
829
+ def _snapshot_call(
830
+ conv: ConversationState, tool_call: Any, parent_id: str, results: dict[str, Any]
831
+ ) -> ToolCall:
832
+ """A record for a call listed in the snapshot: the existing one, or a new one.
833
+
834
+ A kept record with no result yet takes the one from the snapshot's ``tool`` message,
835
+ as ``TOOL_CALL_RESULT`` would: ag-ui-langgraph sends a tool node's result only here
836
+ (DM v1.10). ``rejected`` and ``error`` stay terminal. A kept record that streamed no
837
+ arguments takes the snapshot's, when they parse to an object (DM v1.11): a tool call
838
+ sent in one chunk reaches ag-ui-langgraph's stream with no ``TOOL_CALL_ARGS``.
839
+ """
840
+ existing = _get_call(conv, tool_call.id)
841
+ result = results.get(tool_call.id)
842
+ if existing is not None:
843
+ existing = _with_snapshot_args(existing, tool_call.function.arguments)
844
+ if result is None or existing.result is not None:
845
+ return existing
846
+ status: ToolCallStatus
847
+ if existing.status in (ToolCallStatus.REJECTED, ToolCallStatus.ERROR):
848
+ status = existing.status
849
+ else:
850
+ status = ToolCallStatus.ERROR if result.error else ToolCallStatus.DONE
851
+ return dataclasses.replace(existing, status=status, result=_content_text(result.content))
852
+ name = tool_call.function.name
853
+ call = _end_call(
854
+ ToolCall(
855
+ id=tool_call.id,
856
+ name=name,
857
+ args_text=tool_call.function.arguments,
858
+ parent_message_id=parent_id,
859
+ is_client_tool=name in conv.config.client_tool_names,
860
+ )
861
+ )
862
+ if result is not None:
863
+ status = ToolCallStatus.ERROR if result.error else ToolCallStatus.DONE
864
+ call = dataclasses.replace(call, status=status, result=_content_text(result.content))
865
+ return call
866
+
867
+
868
+ def _messages_snapshot(conv: ConversationState, event: MessagesSnapshotEvent) -> ConversationState:
869
+ """REQ-010.4, DM §7.1: rebuild ``messages`` from the snapshot.
870
+
871
+ Tool calls still listed keep their records (and statuses), taking a missing result
872
+ from their ``tool`` message; others are dropped; calls known only from the snapshot get
873
+ new records. Reasoning messages become records
874
+ linked to the next assistant message (else the previous one); streamed reasoning of
875
+ surviving assistant messages is kept, so encrypted values are not lost. Activity
876
+ messages are not rendered. Repeated IDs and tool messages for unknown calls are
877
+ dropped with warnings (INV-1, INV-3).
878
+ """
879
+ event_type = EventType.MESSAGES_SNAPSHOT.value
880
+ results = {m.tool_call_id: m for m in event.messages if m.role == "tool"}
881
+ messages: list[ChatMessage] = []
882
+ calls: list[ToolCall] = []
883
+ snapshot_reasoning: list[ReasoningRecord] = []
884
+ seen: set[str] = set()
885
+ pending: list[int] = []
886
+ last_assistant: str | None = None
887
+ problems: list[tuple[DiagnosticCode, str]] = []
888
+
889
+ for m in event.messages:
890
+ if m.role == "activity":
891
+ continue
892
+ if m.id in seen:
893
+ problems.append((DiagnosticCode.DUPLICATE_ID, f"dropped repeated message {m.id!r}"))
894
+ continue
895
+ seen.add(m.id)
896
+ if m.role == "reasoning":
897
+ pending.append(len(snapshot_reasoning))
898
+ snapshot_reasoning.append(
899
+ ReasoningRecord(id=m.id, content=m.content or "", encrypted_value=m.encrypted_value)
900
+ )
901
+ continue
902
+ if m.role == "tool":
903
+ if m.tool_call_id not in {c.id for c in calls}:
904
+ problems.append(
905
+ (
906
+ DiagnosticCode.UNKNOWN_TOOL_CALL_ID,
907
+ f"dropped tool message {m.id!r} for unknown tool call {m.tool_call_id!r}",
908
+ )
909
+ )
910
+ continue
911
+ messages.append(
912
+ ChatMessage(
913
+ id=m.id,
914
+ role=MessageRole.TOOL,
915
+ content=_content_text(m.content),
916
+ tool_call_id=m.tool_call_id,
917
+ is_error=bool(m.error),
918
+ )
919
+ )
920
+ continue
921
+ if m.role == "assistant":
922
+ call_ids: list[str] = []
923
+ for tool_call in m.tool_calls or []:
924
+ if tool_call.id in call_ids or any(c.id == tool_call.id for c in calls):
925
+ continue
926
+ calls.append(_snapshot_call(conv, tool_call, m.id, results))
927
+ call_ids.append(tool_call.id)
928
+ messages.append(
929
+ ChatMessage(
930
+ id=m.id,
931
+ role=MessageRole.ASSISTANT,
932
+ content=_content_text(m.content or ""),
933
+ tool_call_ids=tuple(call_ids),
934
+ )
935
+ )
936
+ for index in pending:
937
+ snapshot_reasoning[index] = dataclasses.replace(
938
+ snapshot_reasoning[index], assistant_message_id=m.id
939
+ )
940
+ pending.clear()
941
+ last_assistant = m.id
942
+ continue
943
+ messages.append(
944
+ ChatMessage(id=m.id, role=MessageRole(m.role), content=_content_text(m.content))
945
+ )
946
+
947
+ for index in pending:
948
+ snapshot_reasoning[index] = dataclasses.replace(
949
+ snapshot_reasoning[index], assistant_message_id=last_assistant
950
+ )
951
+ message_ids = {msg.id for msg in messages}
952
+ snapshot_ids = {r.id for r in snapshot_reasoning}
953
+ kept_reasoning = tuple(
954
+ r
955
+ for r in conv.reasoning
956
+ if r.id not in snapshot_ids
957
+ and (r.assistant_message_id is None or r.assistant_message_id in message_ids)
958
+ )
959
+ call_id_set = {c.id for c in calls}
960
+ conv = conv.with_(
961
+ messages=tuple(messages),
962
+ tool_calls=tuple(calls),
963
+ reasoning=(*kept_reasoning, *snapshot_reasoning),
964
+ dirty_tool_args=conv.dirty_tool_args & call_id_set,
965
+ )
966
+ for code, message in problems:
967
+ conv = _diag(conv, "warning", code, message, event_type)
968
+ return conv
969
+
970
+
971
+ _HANDLERS: dict[str, Handler] = {
972
+ EventType.RUN_STARTED: _run_started,
973
+ EventType.RUN_FINISHED: _run_finished,
974
+ EventType.RUN_ERROR: _run_error,
975
+ EventType.STEP_STARTED: _step_started,
976
+ EventType.STEP_FINISHED: _step_finished,
977
+ EventType.TEXT_MESSAGE_START: _text_start,
978
+ EventType.TEXT_MESSAGE_CONTENT: _text_content,
979
+ EventType.TEXT_MESSAGE_END: _text_end,
980
+ EventType.TEXT_MESSAGE_CHUNK: _text_chunk,
981
+ EventType.TOOL_CALL_START: _tool_start,
982
+ EventType.TOOL_CALL_ARGS: _tool_args,
983
+ EventType.TOOL_CALL_END: _tool_end,
984
+ EventType.TOOL_CALL_CHUNK: _tool_chunk,
985
+ EventType.TOOL_CALL_RESULT: _tool_result,
986
+ EventType.STATE_SNAPSHOT: _state_snapshot,
987
+ EventType.STATE_DELTA: _state_delta,
988
+ EventType.MESSAGES_SNAPSHOT: _messages_snapshot,
989
+ EventType.CUSTOM: _custom,
990
+ EventType.REASONING_START: _reasoning_boundary,
991
+ EventType.REASONING_MESSAGE_START: _reasoning_start,
992
+ EventType.REASONING_MESSAGE_CONTENT: _reasoning_content,
993
+ EventType.REASONING_MESSAGE_END: _reasoning_end,
994
+ EventType.REASONING_MESSAGE_CHUNK: _reasoning_chunk,
995
+ EventType.REASONING_END: _reasoning_boundary,
996
+ EventType.REASONING_ENCRYPTED_VALUE: _reasoning_encrypted,
997
+ }
998
+
999
+ HANDLED_EVENT_TYPES: frozenset[str] = frozenset(_HANDLERS) | _IGNORED
1000
+ """Event types the reducer applies or deliberately ignores (all others are unknown)."""
1001
+
1002
+
1003
+ # --- public API ----------------------------------------------------------------------------
1004
+
1005
+
1006
+ def reduce(state: ConversationState, event: BaseEvent) -> ConversationState:
1007
+ """Apply one validated AG-UI event to ``state`` and return the new state.
1008
+
1009
+ Pure, deterministic and total (REQ-005.1-2): it never mutates ``state`` and never
1010
+ raises for a validated event; problems become diagnostics on the result.
1011
+ """
1012
+ event_type = _type_name(event.type)
1013
+ if event_type in _IGNORED:
1014
+ return _diag(
1015
+ state,
1016
+ "debug",
1017
+ DiagnosticCode.IGNORED_EVENT,
1018
+ f"{event_type} is accepted but not rendered",
1019
+ event_type,
1020
+ )
1021
+ handler = _HANDLERS.get(event_type)
1022
+ if handler is not None and state.run_status is not RunStatus.RUNNING:
1023
+ # Only a running run accepts events: nothing can reopen a stream after the end
1024
+ # (INV-5). RUN_FINISHED of another run still reports itself as stale first.
1025
+ if isinstance(event, RunFinishedEvent) and event.run_id not in state.last_run_ids:
1026
+ return _stale(state, event.run_id, event_type)
1027
+ return _after_end(state, event_type)
1028
+ if handler is None:
1029
+ return _diag(
1030
+ state,
1031
+ "debug",
1032
+ DiagnosticCode.UNKNOWN_EVENT_TYPE,
1033
+ f"skipped {event_type}: no handler for this event type",
1034
+ event_type,
1035
+ )
1036
+ return handler(state, event)
1037
+
1038
+
1039
+ def parse_event(raw: object) -> BaseEvent | Diagnostic:
1040
+ """Validate one wire event (camelCase JSON object) into its ``ag_ui.core`` model.
1041
+
1042
+ Returns a :class:`Diagnostic` instead of raising: ``UNKNOWN_EVENT_TYPE`` (debug) for
1043
+ unknown or deprecated types such as ``THINKING_*`` (REQ-012.2), and ``INVALID_EVENT``
1044
+ (warning, naming the type) for anything that fails validation (REQ-012.3).
1045
+ """
1046
+ if not isinstance(raw, Mapping):
1047
+ return Diagnostic(
1048
+ level="warning",
1049
+ code=DiagnosticCode.INVALID_EVENT,
1050
+ message=f"skipped an event that is not a JSON object ({type(raw).__name__})",
1051
+ )
1052
+ event_type = raw.get("type")
1053
+ if not isinstance(event_type, str):
1054
+ return Diagnostic(
1055
+ level="warning",
1056
+ code=DiagnosticCode.INVALID_EVENT,
1057
+ message="skipped an event without a string 'type'",
1058
+ )
1059
+ if event_type not in _KNOWN_TYPES:
1060
+ return Diagnostic(
1061
+ level="debug",
1062
+ code=DiagnosticCode.UNKNOWN_EVENT_TYPE,
1063
+ message=f"skipped {event_type}: unknown to the installed ag-ui-protocol",
1064
+ event_type=event_type,
1065
+ )
1066
+ try:
1067
+ event: BaseEvent = _EVENT_ADAPTER.validate_python(raw)
1068
+ except ValidationError as exc:
1069
+ locations = ", ".join(
1070
+ ".".join(str(part) for part in error["loc"]) or "<event>" for error in exc.errors()
1071
+ )
1072
+ return Diagnostic(
1073
+ level="warning",
1074
+ code=DiagnosticCode.INVALID_EVENT,
1075
+ message=f"skipped an invalid {event_type} event (invalid fields: {locations})",
1076
+ event_type=event_type,
1077
+ )
1078
+ return event