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,589 @@
1
+ """Pure command functions: state transitions that are not AG-UI events (API §11).
2
+
3
+ Each takes a ``ConversationState`` and returns a new one (or the same object when the
4
+ command does not apply). They generate IDs (UUID4 by default, DM R-7) but take ``new_id``
5
+ so a sequence of commands can be reproduced.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import uuid
12
+ from collections.abc import Callable, Iterable
13
+ from dataclasses import replace
14
+
15
+ from ag_ui.core import ResumeEntry
16
+
17
+ from reflex_agentkit.core._patch import PatchError, apply_patch
18
+ from reflex_agentkit.core.conversation import ConversationState
19
+ from reflex_agentkit.core.errors import ErrorCode
20
+ from reflex_agentkit.core.models import (
21
+ ChatMessage,
22
+ Diagnostic,
23
+ DiagnosticCode,
24
+ InterruptAnswer,
25
+ JsonObject,
26
+ JsonValue,
27
+ MessageRole,
28
+ OpenInterrupt,
29
+ ReducerConfig,
30
+ RunStatus,
31
+ ToolCall,
32
+ ToolCallStatus,
33
+ )
34
+ from reflex_agentkit.core.reducer import close_streams
35
+
36
+ __all__ = [
37
+ "CANCELLED_RESULT",
38
+ "LOCAL_INTERRUPT_PREFIX",
39
+ "MAX_PATCH_OPS",
40
+ "MAX_PAYLOAD_BYTES",
41
+ "all_answered",
42
+ "apply_approval",
43
+ "apply_cancel",
44
+ "apply_client_tool_result",
45
+ "apply_failure",
46
+ "apply_interrupt_response",
47
+ "apply_rejection",
48
+ "apply_shared_state_patch",
49
+ "apply_shared_state_write",
50
+ "begin_retry",
51
+ "begin_run",
52
+ "new_thread",
53
+ "request_local_approvals",
54
+ "resume_entries",
55
+ "revert_shared_state",
56
+ "start_client_tool_round",
57
+ ]
58
+
59
+ MAX_PAYLOAD_BYTES = 64 * 1024
60
+ """Cap on the serialized JSON of ``edited_args`` and interrupt responses (TD §6)."""
61
+
62
+ CANCELLED_RESULT = "cancelled"
63
+ """Result text of the tool calls a cancellation interrupts (REQ-007.4, REQ-015.2)."""
64
+
65
+
66
+ def _uuid4() -> str:
67
+ return str(uuid.uuid4())
68
+
69
+
70
+ def _too_large(value: JsonValue) -> bool:
71
+ return len(json.dumps(value, ensure_ascii=False).encode()) > MAX_PAYLOAD_BYTES
72
+
73
+
74
+ def _parse_json(text: str) -> tuple[bool, JsonValue]:
75
+ """``(True, value)`` if ``text`` is finite JSON within the nesting limit."""
76
+
77
+ def reject(name: str) -> object:
78
+ raise ValueError(name)
79
+
80
+ try:
81
+ return True, json.loads(text, parse_constant=reject)
82
+ except (ValueError, RecursionError):
83
+ return False, None
84
+
85
+
86
+ def all_answered(conv: ConversationState) -> bool:
87
+ """Whether the pause has interrupts and every one of them has an answer (INV-4)."""
88
+ return bool(conv.interrupts) and all(i.id in conv.answers for i in conv.interrupts)
89
+
90
+
91
+ def _record(
92
+ conv: ConversationState, answer: InterruptAnswer, **changes: object
93
+ ) -> ConversationState:
94
+ """Store ``answer``; leave the pause once every interrupt is answered (DM v1.6)."""
95
+ answers = {**conv.answers, answer.interrupt_id: answer}
96
+ done = all(i.id in answers for i in conv.interrupts)
97
+ status = RunStatus.IDLE if done else conv.run_status
98
+ return conv.with_(answers=answers, run_status=status, **changes)
99
+
100
+
101
+ def _approvable(conv: ConversationState, call_id: str) -> tuple[ToolCall, OpenInterrupt] | None:
102
+ """The pending call and its open, unanswered interrupt, if there is one (REQ-013.6)."""
103
+ if conv.run_status is not RunStatus.AWAITING_INPUT:
104
+ return None
105
+ index = conv._tool_index.get(call_id)
106
+ if index is None:
107
+ return None
108
+ call = conv.tool_calls[index]
109
+ if call.status is not ToolCallStatus.PENDING_APPROVAL or call.interrupt_id in conv.answers:
110
+ return None
111
+ interrupt = next((i for i in conv.interrupts if i.id == call.interrupt_id), None)
112
+ return None if interrupt is None else (call, interrupt)
113
+
114
+
115
+ def _with_call(conv: ConversationState, call: ToolCall) -> tuple[ToolCall, ...]:
116
+ calls = list(conv.tool_calls)
117
+ calls[conv._tool_index[call.id]] = call
118
+ return tuple(calls)
119
+
120
+
121
+ def apply_approval(
122
+ conv: ConversationState, call_id: str, edited_args: object = None
123
+ ) -> ConversationState:
124
+ """Approve a pending tool call, optionally with edited arguments (REQ-013.2-3, 013.7).
125
+
126
+ A ``str`` ``edited_args`` is parsed as JSON. Edits that are not a JSON object or that
127
+ exceed :data:`MAX_PAYLOAD_BYTES` serialized set ``INVALID_EDITED_ARGS`` and leave the
128
+ call pending. Unknown or non-pending calls are ignored (REQ-013.6).
129
+ """
130
+ target = _approvable(conv, call_id)
131
+ if target is None:
132
+ return conv
133
+ call, interrupt = target
134
+ payload: JsonObject = {"approved": True}
135
+ args = call.args
136
+ if edited_args is not None:
137
+ if isinstance(edited_args, str):
138
+ ok, edited_args = _parse_json(edited_args)
139
+ if not ok:
140
+ return _invalid_edit(conv, "the edited arguments are not valid JSON")
141
+ if not isinstance(edited_args, dict):
142
+ return _invalid_edit(conv, "the edited arguments must be a JSON object")
143
+ try:
144
+ too_large = _too_large(edited_args)
145
+ except (TypeError, ValueError, RecursionError):
146
+ return _invalid_edit(conv, "the edited arguments are not serializable as JSON")
147
+ if too_large:
148
+ return _invalid_edit(conv, f"the edited arguments exceed {MAX_PAYLOAD_BYTES} bytes")
149
+ args = edited_args
150
+ payload["editedArgs"] = edited_args
151
+ running = replace(call, status=ToolCallStatus.RUNNING, args=args)
152
+ answer = InterruptAnswer(interrupt_id=interrupt.id, status="resolved", payload=payload)
153
+ return _record(conv, answer, tool_calls=_with_call(conv, running))
154
+
155
+
156
+ def _invalid_edit(conv: ConversationState, reason: str) -> ConversationState:
157
+ return conv.with_(
158
+ error=f"Invalid edited arguments: {reason}.", error_code=ErrorCode.INVALID_EDITED_ARGS
159
+ )
160
+
161
+
162
+ def apply_rejection(
163
+ conv: ConversationState,
164
+ call_id: str,
165
+ reason: str | None = None,
166
+ *,
167
+ new_id: Callable[[], str] = _uuid4,
168
+ ) -> ConversationState:
169
+ """Reject a pending tool call (REQ-013.4, REQ-018.3).
170
+
171
+ A local (client-tool) approval is answered locally: a rejection tool message is added
172
+ so the follow-up run tells the agent.
173
+ """
174
+ target = _approvable(conv, call_id)
175
+ if target is None:
176
+ return conv
177
+ call, interrupt = target
178
+ payload: JsonObject = {"approved": False}
179
+ if reason is not None:
180
+ payload["reason"] = reason
181
+ result = f"Rejected by the user: {reason}" if reason else "Rejected by the user."
182
+ rejected = replace(call, status=ToolCallStatus.REJECTED, result=result)
183
+ messages = conv.messages
184
+ if interrupt.is_local:
185
+ tool_msg = ChatMessage(
186
+ id=new_id(), role=MessageRole.TOOL, content=result, tool_call_id=call.id, is_error=True
187
+ )
188
+ messages = (*messages, tool_msg)
189
+ answer = InterruptAnswer(interrupt_id=interrupt.id, status="resolved", payload=payload)
190
+ return _record(conv, answer, tool_calls=_with_call(conv, rejected), messages=messages)
191
+
192
+
193
+ def apply_interrupt_response(conv: ConversationState, payload: object) -> ConversationState:
194
+ """Answer the first pending generic interrupt (REQ-014.2-4).
195
+
196
+ A ``str`` payload is parsed as JSON when it is valid JSON and kept as a string
197
+ otherwise. Payloads above :data:`MAX_PAYLOAD_BYTES` serialized are ignored with a
198
+ warning diagnostic. With no generic interrupt pending, nothing changes.
199
+ """
200
+ if conv.run_status is not RunStatus.AWAITING_INPUT:
201
+ return conv
202
+ target = next(
203
+ (
204
+ i
205
+ for i in conv.interrupts
206
+ if i.tool_call_id is None and not i.is_local and i.id not in conv.answers
207
+ ),
208
+ None,
209
+ )
210
+ if target is None:
211
+ return conv
212
+ value: object = payload
213
+ if isinstance(payload, str):
214
+ ok, parsed = _parse_json(payload)
215
+ if ok:
216
+ value = parsed
217
+ try:
218
+ too_large = _too_large(value) # type: ignore[arg-type]
219
+ except (TypeError, ValueError, RecursionError):
220
+ return _ignored(conv, "the interrupt response is not serializable as JSON")
221
+ if too_large:
222
+ return _ignored(conv, f"the interrupt response exceeds {MAX_PAYLOAD_BYTES} bytes")
223
+ answer = InterruptAnswer(interrupt_id=target.id, status="resolved", payload=value) # type: ignore[arg-type]
224
+ return _record(conv, answer)
225
+
226
+
227
+ def _ignored(conv: ConversationState, reason: str) -> ConversationState:
228
+ diagnostic = Diagnostic(
229
+ level="warning", code=DiagnosticCode.IGNORED_EVENT, message=f"Ignored: {reason}."
230
+ )
231
+ return conv.with_(diagnostics=(*conv.diagnostics, diagnostic))
232
+
233
+
234
+ def apply_cancel(conv: ConversationState) -> ConversationState:
235
+ """Cancel the run (REQ-007.4, REQ-015.1-2).
236
+
237
+ While running, partial content is kept, streaming stops, and streaming or running
238
+ calls end in ``error`` with the result ``"cancelled"``. While paused, pending approvals
239
+ become ``rejected``; the non-local interrupt IDs move to ``abandoned_interrupt_ids``
240
+ so the next run cancels them (REQ-015.3), and the pause bookkeeping is cleared. In
241
+ any other status this is a no-op.
242
+ """
243
+ if conv.run_status is RunStatus.RUNNING:
244
+ messages = tuple(replace(m, streaming=False) if m.streaming else m for m in conv.messages)
245
+ open_states = (ToolCallStatus.STREAMING, ToolCallStatus.RUNNING)
246
+ calls = tuple(
247
+ replace(c, status=ToolCallStatus.ERROR, result=CANCELLED_RESULT)
248
+ if c.status in open_states
249
+ else c
250
+ for c in conv.tool_calls
251
+ )
252
+ return conv.with_(
253
+ run_status=RunStatus.CANCELLED,
254
+ messages=messages,
255
+ tool_calls=calls,
256
+ dirty_tool_args=frozenset(),
257
+ current_step="",
258
+ )
259
+ if conv.run_status is RunStatus.AWAITING_INPUT:
260
+ calls = tuple(
261
+ replace(c, status=ToolCallStatus.REJECTED, result=CANCELLED_RESULT)
262
+ if c.status is ToolCallStatus.PENDING_APPROVAL
263
+ else c
264
+ for c in conv.tool_calls
265
+ )
266
+ abandoned = tuple(i.id for i in conv.interrupts if not i.is_local)
267
+ return conv.with_(
268
+ run_status=RunStatus.CANCELLED,
269
+ tool_calls=calls,
270
+ interrupts=(),
271
+ answers={},
272
+ abandoned_interrupt_ids=(*conv.abandoned_interrupt_ids, *abandoned),
273
+ )
274
+ return conv
275
+
276
+
277
+ def apply_client_tool_result(
278
+ conv: ConversationState,
279
+ call_id: str,
280
+ result: str,
281
+ *,
282
+ is_error: bool = False,
283
+ new_id: Callable[[], str] = _uuid4,
284
+ ) -> ConversationState:
285
+ """Record a client-tool handler's result (REQ-018.2, REQ-018.4).
286
+
287
+ Only a running client-tool call takes a result; it ends ``done`` (or ``error``) and a
288
+ tool message is appended. A handler error is a tool result, never a run error.
289
+ """
290
+ index = conv._tool_index.get(call_id)
291
+ if index is None:
292
+ return conv
293
+ call = conv.tool_calls[index]
294
+ if not call.is_client_tool or call.status is not ToolCallStatus.RUNNING:
295
+ return conv
296
+ status = ToolCallStatus.ERROR if is_error else ToolCallStatus.DONE
297
+ finished = replace(call, status=status, result=result)
298
+ tool_msg = ChatMessage(
299
+ id=new_id(), role=MessageRole.TOOL, content=result, tool_call_id=call_id, is_error=is_error
300
+ )
301
+ return conv.with_(tool_calls=_with_call(conv, finished), messages=(*conv.messages, tool_msg))
302
+
303
+
304
+ LOCAL_INTERRUPT_PREFIX = "local-"
305
+ """Prefix of the interrupt IDs of client-tool approvals (DM §3.2, §3.4)."""
306
+
307
+
308
+ def request_local_approvals(conv: ConversationState, call_ids: Iterable[str]) -> ConversationState:
309
+ """Pause on local approvals for approval-required client tools (REQ-018.3, Flow B.2).
310
+
311
+ Each named call that is a running client-tool call moves to ``pending_approval``
312
+ behind an ``OpenInterrupt`` with ``is_local=True`` and the ID ``local-<call_id>``.
313
+ Local interrupts are answered by ``approve_tool``/``reject_tool`` and never sent
314
+ upstream. Outside a running run, or with no such call, this is a no-op.
315
+ """
316
+ if conv.run_status is not RunStatus.RUNNING:
317
+ return conv
318
+ calls = list(conv.tool_calls)
319
+ opened: list[OpenInterrupt] = []
320
+ for call_id in dict.fromkeys(call_ids):
321
+ index = conv._tool_index.get(call_id)
322
+ if index is None:
323
+ continue
324
+ call = calls[index]
325
+ if not call.is_client_tool or call.status is not ToolCallStatus.RUNNING:
326
+ continue
327
+ interrupt_id = f"{LOCAL_INTERRUPT_PREFIX}{call_id}"
328
+ calls[index] = replace(
329
+ call, status=ToolCallStatus.PENDING_APPROVAL, interrupt_id=interrupt_id
330
+ )
331
+ opened.append(
332
+ OpenInterrupt(id=interrupt_id, reason="tool_call", tool_call_id=call_id, is_local=True)
333
+ )
334
+ if not opened:
335
+ return conv
336
+ return conv.with_(
337
+ tool_calls=tuple(calls),
338
+ interrupts=tuple(opened),
339
+ answers={},
340
+ run_status=RunStatus.AWAITING_INPUT,
341
+ )
342
+
343
+
344
+ def start_client_tool_round(conv: ConversationState, *, max_rounds: int) -> ConversationState:
345
+ """Count one client-tool round of the turn, or stop a looping model (DD-009).
346
+
347
+ The round past ``max_rounds`` ends the run in ``error`` with
348
+ ``CLIENT_TOOL_LOOP_LIMIT``. Outside a running run this is a no-op.
349
+ """
350
+ if conv.run_status is not RunStatus.RUNNING:
351
+ return conv
352
+ if conv.client_tool_rounds >= max_rounds:
353
+ message = f"the agent called client tools in more than {max_rounds} consecutive rounds"
354
+ return apply_failure(conv, ErrorCode.CLIENT_TOOL_LOOP_LIMIT, message)
355
+ return conv.with_(client_tool_rounds=conv.client_tool_rounds + 1)
356
+
357
+
358
+ def apply_failure(conv: ConversationState, code: ErrorCode, message: str) -> ConversationState:
359
+ """End a running run in ``error`` after a transport or agent failure (TD Flow E).
360
+
361
+ The content streamed so far is kept: open messages stop streaming and calls whose
362
+ arguments were still streaming fail as incomplete, exactly as for ``RUN_ERROR``
363
+ (REQ-004.3-4). Outside a running run this is a no-op.
364
+ """
365
+ if conv.run_status is not RunStatus.RUNNING:
366
+ return conv
367
+ return close_streams(conv, finished=False).with_(
368
+ run_status=RunStatus.ERROR, error=message, error_code=code
369
+ )
370
+
371
+
372
+ def _resume_answers(conv: ConversationState) -> tuple[InterruptAnswer, ...]:
373
+ """The resume entries of the next run (DM §3.10 v1.6, REQ-013.5, REQ-015.3)."""
374
+ cancelled = tuple(
375
+ InterruptAnswer(interrupt_id=i, status="cancelled") for i in conv.abandoned_interrupt_ids
376
+ )
377
+ resolved = tuple(
378
+ conv.answers[i.id] for i in conv.interrupts if not i.is_local and i.id in conv.answers
379
+ )
380
+ return (*cancelled, *resolved)
381
+
382
+
383
+ def resume_entries(conv: ConversationState) -> list[ResumeEntry]:
384
+ """The ``RunAgentInput.resume`` entries captured by the last ``begin_run``."""
385
+ return [
386
+ ResumeEntry(interrupt_id=a.interrupt_id, status=a.status)
387
+ if a.status == "cancelled"
388
+ else ResumeEntry(interrupt_id=a.interrupt_id, status=a.status, payload=a.payload)
389
+ for a in conv.resume
390
+ ]
391
+
392
+
393
+ def begin_run(
394
+ conv: ConversationState,
395
+ *,
396
+ config: ReducerConfig,
397
+ user_text: str | None = None,
398
+ resume: bool = False,
399
+ new_id: Callable[[], str] = _uuid4,
400
+ ) -> ConversationState:
401
+ """Start a run (REQ-001.1-3, REQ-002.1-2, REQ-010.2, REQ-013.5, REQ-015.3).
402
+
403
+ With ``user_text`` the human's turn is appended, a new turn of run IDs starts and the
404
+ client-tool round counter resets. Without it (a resume or a client-tool follow-up) the
405
+ run joins the current turn, so late events of the turn's earlier runs are still
406
+ recognized. Either way the answers and abandoned interrupts are captured in
407
+ ``resume`` and the pause bookkeeping is cleared. While an interrupt is still
408
+ unanswered (``awaiting_input``) no run can start, so the call is a no-op.
409
+
410
+ Args:
411
+ conv: The current conversation.
412
+ config: The reducer settings for this run, built from the ``AgentState`` ClassVars.
413
+ user_text: The validated text the user sent, if this run starts a new turn.
414
+ resume: Whether this run answers interrupts. Informational: the entries come from
415
+ the recorded answers either way.
416
+ new_id: ID factory; UUID4 strings by default.
417
+ """
418
+ del resume
419
+ if conv.run_status is RunStatus.AWAITING_INPUT:
420
+ return conv # REQ-015.1, DM v1.8: unanswered interrupts block a new run
421
+ thread_id = conv.thread_id or new_id()
422
+ run_id = new_id()
423
+ messages = conv.messages
424
+ rounds = conv.client_tool_rounds
425
+ if user_text is not None:
426
+ user_message = ChatMessage(id=new_id(), role=MessageRole.USER, content=user_text)
427
+ messages = (*messages, user_message)
428
+ last_run_ids: tuple[str, ...] = (run_id,)
429
+ rounds = 0
430
+ else:
431
+ last_run_ids = (*conv.last_run_ids, run_id)
432
+ return conv.with_(
433
+ config=config,
434
+ thread_id=thread_id,
435
+ run_id=run_id,
436
+ run_status=RunStatus.RUNNING,
437
+ error="",
438
+ error_code=None,
439
+ messages=messages,
440
+ last_run_ids=last_run_ids,
441
+ resume=_resume_answers(conv),
442
+ interrupts=(),
443
+ answers={},
444
+ abandoned_interrupt_ids=(),
445
+ client_tool_rounds=rounds,
446
+ )
447
+
448
+
449
+ MAX_PATCH_OPS = 256
450
+ """The most operations one app-side ``patch_shared_state`` call may carry."""
451
+
452
+
453
+ def _json_object_copy(value: object) -> JsonObject | None:
454
+ """A deep copy of ``value`` if it is a finite JSON object within the payload cap."""
455
+ if not isinstance(value, dict):
456
+ return None
457
+ try:
458
+ text = json.dumps(value, ensure_ascii=False, allow_nan=False)
459
+ except (TypeError, ValueError, RecursionError):
460
+ return None
461
+ if len(text.encode()) > MAX_PAYLOAD_BYTES:
462
+ return None
463
+ copied: JsonObject = json.loads(text)
464
+ return copied
465
+
466
+
467
+ def _shared_state_error(
468
+ conv: ConversationState, code: ErrorCode, message: str
469
+ ) -> ConversationState:
470
+ return conv.with_(error=message, error_code=code)
471
+
472
+
473
+ def apply_shared_state_write(conv: ConversationState, value: object) -> ConversationState:
474
+ """Replace ``shared_state`` from app code (REQ-017.1-2, INV-10).
475
+
476
+ Rejected while running (``SHARED_STATE_LOCKED``: the agent owns the state) and when
477
+ the value is not a JSON object within :data:`MAX_PAYLOAD_BYTES`
478
+ (``SHARED_STATE_INVALID``). The stored value is a copy; ``shared_state_stale`` clears.
479
+ """
480
+ if conv.run_status is RunStatus.RUNNING:
481
+ return _shared_state_error(
482
+ conv, ErrorCode.SHARED_STATE_LOCKED, "The shared state cannot change during a run."
483
+ )
484
+ copied = _json_object_copy(value)
485
+ if copied is None:
486
+ return _shared_state_error(
487
+ conv,
488
+ ErrorCode.SHARED_STATE_INVALID,
489
+ f"The shared state must be a JSON object of at most {MAX_PAYLOAD_BYTES} bytes.",
490
+ )
491
+ return conv.with_(shared_state=copied, shared_state_stale=False)
492
+
493
+
494
+ def apply_shared_state_patch(conv: ConversationState, ops: object) -> ConversationState:
495
+ """Apply RFC 6902 ``ops`` to ``shared_state`` atomically (REQ-017.1-2).
496
+
497
+ Rejected while running (``SHARED_STATE_LOCKED``). A malformed, oversized or failing
498
+ patch changes nothing and sets ``SHARED_STATE_PATCH_FAILED``.
499
+ """
500
+ if conv.run_status is RunStatus.RUNNING:
501
+ return _shared_state_error(
502
+ conv, ErrorCode.SHARED_STATE_LOCKED, "The shared state cannot change during a run."
503
+ )
504
+ failed = _shared_state_error(
505
+ conv, ErrorCode.SHARED_STATE_PATCH_FAILED, "The shared-state patch could not be applied."
506
+ )
507
+ wrapped = _json_object_copy({"ops": ops})
508
+ if wrapped is None or not isinstance(wrapped["ops"], list):
509
+ return failed
510
+ operations = wrapped["ops"]
511
+ if len(operations) > MAX_PATCH_OPS or not all(isinstance(op, dict) for op in operations):
512
+ return failed
513
+ # One op at a time, with the size cap after each: ``copy``/``move`` ops can double the
514
+ # document, so checking only the final result would allow exponential growth first.
515
+ result = conv.shared_state
516
+ for op in operations:
517
+ try:
518
+ result = apply_patch(result, [op]) # type: ignore[list-item]
519
+ except PatchError:
520
+ return failed
521
+ if _json_object_copy(result) is None:
522
+ return failed
523
+ return conv.with_(shared_state=result, shared_state_stale=False)
524
+
525
+
526
+ def revert_shared_state(conv: ConversationState, previous: JsonObject) -> ConversationState:
527
+ """Reject an agent update that fails ``shared_state_model`` (REQ-016.5).
528
+
529
+ It behaves like a failed delta (REQ-016.3): ``previous`` is kept, the state is marked
530
+ stale, ``error_code`` is ``SHARED_STATE_INVALID`` and a warning is recorded.
531
+ """
532
+ diagnostic = Diagnostic(
533
+ level="warning",
534
+ code=DiagnosticCode.INVALID_EVENT,
535
+ message="The shared state did not validate against shared_state_model.",
536
+ )
537
+ return conv.with_(
538
+ shared_state=previous,
539
+ shared_state_stale=True,
540
+ error_code=ErrorCode.SHARED_STATE_INVALID,
541
+ diagnostics=(*conv.diagnostics, diagnostic),
542
+ )
543
+
544
+
545
+ def begin_retry(
546
+ conv: ConversationState,
547
+ *,
548
+ config: ReducerConfig,
549
+ new_id: Callable[[], str] = _uuid4,
550
+ ) -> ConversationState:
551
+ """Start the last user turn again (REQ-028.1, API §4.3).
552
+
553
+ Allowed only after ``error`` or ``cancelled``. Everything after the last ``user``
554
+ message is dropped (with its tool calls and reasoning), and a new turn starts with a
555
+ new ``run_id``; the round counter resets. Without a user message this is a no-op.
556
+ """
557
+ if conv.run_status not in (RunStatus.ERROR, RunStatus.CANCELLED):
558
+ return conv
559
+ last_user = next(
560
+ (
561
+ i
562
+ for i in range(len(conv.messages) - 1, -1, -1)
563
+ if conv.messages[i].role is MessageRole.USER
564
+ ),
565
+ None,
566
+ )
567
+ if last_user is None:
568
+ return conv
569
+ messages = conv.messages[: last_user + 1]
570
+ kept = {m.id for m in messages}
571
+ referenced = {call_id for m in messages for call_id in m.tool_call_ids}
572
+ calls = tuple(c for c in conv.tool_calls if c.id in referenced or c.parent_message_id in kept)
573
+ reasoning = tuple(r for r in conv.reasoning if r.assistant_message_id in kept)
574
+ truncated = conv.with_(
575
+ messages=messages,
576
+ tool_calls=calls,
577
+ reasoning=reasoning,
578
+ dirty_tool_args=frozenset(),
579
+ current_step="",
580
+ )
581
+ started = begin_run(truncated, config=config, new_id=new_id)
582
+ return started.with_(last_run_ids=(started.run_id,), client_tool_rounds=0)
583
+
584
+
585
+ def new_thread(conv: ConversationState, *, new_id: Callable[[], str] = _uuid4) -> ConversationState:
586
+ """Start a new conversation (REQ-002.3-4): cancel a live run, then reset everything."""
587
+ if conv.run_status is RunStatus.RUNNING:
588
+ conv = apply_cancel(conv)
589
+ return ConversationState.initial(conv.config).with_(thread_id=new_id())
@@ -0,0 +1,93 @@
1
+ """The ``ConversationState`` aggregate: the single source of truth of a conversation.
2
+
3
+ It lives in the backend-only Reflex var ``_conv`` (DM §1, §3.10) and is only ever
4
+ replaced, never mutated: the reducer and the commands build new instances through
5
+ :meth:`ConversationState.with_`, which also rebuilds the derived lookup indexes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ from typing import Any, ClassVar, Self
12
+
13
+ from reflex_agentkit.core.errors import ErrorCode
14
+ from reflex_agentkit.core.models import (
15
+ ChatMessage,
16
+ CustomEventRecord,
17
+ Diagnostic,
18
+ InterruptAnswer,
19
+ JsonObject,
20
+ OpenInterrupt,
21
+ ReasoningRecord,
22
+ ReducerConfig,
23
+ RunStatus,
24
+ TolerantPickle,
25
+ ToolCall,
26
+ )
27
+
28
+ __all__ = ["ConversationState"]
29
+
30
+ SCHEMA_VERSION = 1
31
+
32
+
33
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
34
+ class ConversationState(TolerantPickle):
35
+ """Everything the reducer knows about one conversation (DM §3.10)."""
36
+
37
+ _schema_version: ClassVar[int] = SCHEMA_VERSION
38
+ _derived_fields: ClassVar[frozenset[str]] = frozenset({"_tool_index", "_message_index"})
39
+
40
+ config: ReducerConfig
41
+ schema_version: int = SCHEMA_VERSION
42
+ thread_id: str = ""
43
+ run_id: str = ""
44
+ run_status: RunStatus = RunStatus.IDLE
45
+ error: str = ""
46
+ error_code: ErrorCode | None = None
47
+ current_step: str = ""
48
+ messages: tuple[ChatMessage, ...] = ()
49
+ tool_calls: tuple[ToolCall, ...] = ()
50
+ reasoning: tuple[ReasoningRecord, ...] = ()
51
+ shared_state: JsonObject = dataclasses.field(default_factory=dict)
52
+ shared_state_stale: bool = False
53
+ interrupts: tuple[OpenInterrupt, ...] = ()
54
+ answers: dict[str, InterruptAnswer] = dataclasses.field(default_factory=dict)
55
+ abandoned_interrupt_ids: tuple[str, ...] = ()
56
+ resume: tuple[InterruptAnswer, ...] = ()
57
+ custom_events: tuple[CustomEventRecord, ...] = ()
58
+ client_tool_rounds: int = 0
59
+ last_run_ids: tuple[str, ...] = ()
60
+ diagnostics: tuple[Diagnostic, ...] = ()
61
+ dirty_tool_args: frozenset[str] = frozenset()
62
+
63
+ # Derived lookups (H-1, H-2): rebuilt on construction, excluded from == and pickle.
64
+ _tool_index: dict[str, int] = dataclasses.field(
65
+ init=False, compare=False, repr=False, default_factory=dict
66
+ )
67
+ _message_index: dict[str, int] = dataclasses.field(
68
+ init=False, compare=False, repr=False, default_factory=dict
69
+ )
70
+
71
+ def __post_init__(self) -> None:
72
+ object.__setattr__(self, "_tool_index", {c.id: i for i, c in enumerate(self.tool_calls)})
73
+ object.__setattr__(self, "_message_index", {m.id: i for i, m in enumerate(self.messages)})
74
+
75
+ @classmethod
76
+ def initial(cls, config: ReducerConfig) -> Self:
77
+ """Return an idle, empty conversation that uses ``config``."""
78
+ return cls(config=config)
79
+
80
+ def with_(self, **changes: Any) -> Self:
81
+ """Return a copy with ``changes`` applied and the derived indexes rebuilt."""
82
+ return dataclasses.replace(self, **changes)
83
+
84
+ @classmethod
85
+ def _from_state(cls, state: dict[str, Any]) -> Self:
86
+ """Restore tolerantly (DM §8), upgrading older payloads to the current schema.
87
+
88
+ There are no upgrade steps yet: version 1 is the first. A future version adds
89
+ its steps here, keyed on ``state.get("schema_version", 1)``.
90
+ """
91
+ upgraded = dict(state)
92
+ upgraded["schema_version"] = SCHEMA_VERSION
93
+ return super(ConversationState, cls)._from_state(upgraded)