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,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)
|