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.
- reflex_agentkit/__init__.py +68 -0
- reflex_agentkit/adapters/__init__.py +1 -0
- reflex_agentkit/adapters/_require.py +27 -0
- reflex_agentkit/adapters/langgraph.py +277 -0
- reflex_agentkit/adapters/pydantic_ai.py +93 -0
- reflex_agentkit/components/__init__.py +21 -0
- reflex_agentkit/components/approval.py +123 -0
- reflex_agentkit/components/chat.py +42 -0
- reflex_agentkit/components/composer.py +44 -0
- reflex_agentkit/components/messages.py +104 -0
- reflex_agentkit/components/shared_state.py +32 -0
- reflex_agentkit/components/status.py +41 -0
- reflex_agentkit/components/tools.py +97 -0
- reflex_agentkit/core/__init__.py +56 -0
- reflex_agentkit/core/_log.py +47 -0
- reflex_agentkit/core/_patch.py +46 -0
- reflex_agentkit/core/clients/__init__.py +14 -0
- reflex_agentkit/core/clients/base.py +71 -0
- reflex_agentkit/core/clients/http.py +147 -0
- reflex_agentkit/core/clients/in_process.py +67 -0
- reflex_agentkit/core/clients/sse.py +75 -0
- reflex_agentkit/core/commands.py +589 -0
- reflex_agentkit/core/conversation.py +93 -0
- reflex_agentkit/core/errors.py +74 -0
- reflex_agentkit/core/models.py +243 -0
- reflex_agentkit/core/reducer.py +1078 -0
- reflex_agentkit/core/run_input.py +122 -0
- reflex_agentkit/core/tools.py +204 -0
- reflex_agentkit/py.typed +0 -0
- reflex_agentkit/state/__init__.py +5 -0
- reflex_agentkit/state/_driver.py +520 -0
- reflex_agentkit/state/_projection.py +120 -0
- reflex_agentkit/state/_registry.py +46 -0
- reflex_agentkit/state/agent_state.py +496 -0
- reflex_agentkit-0.1.0.dist-info/METADATA +457 -0
- reflex_agentkit-0.1.0.dist-info/RECORD +38 -0
- reflex_agentkit-0.1.0.dist-info/WHEEL +4 -0
- 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
|