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,496 @@
|
|
|
1
|
+
"""``AgentState``: the Reflex state mixin (API §4, TD §3.3, DD-004, DD-006, DD-008).
|
|
2
|
+
|
|
3
|
+
The conversation lives in one backend-only var, ``_conv``, which Reflex never sends to
|
|
4
|
+
the browser. The public vars are a projection of it (DM §7.2), rewritten at every flush.
|
|
5
|
+
Configuration is class-level (``ClassVar``) and is never a var (REQ-025.1).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import typing
|
|
12
|
+
from collections.abc import AsyncIterator, Sequence
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from typing import Any, ClassVar
|
|
15
|
+
|
|
16
|
+
import pydantic
|
|
17
|
+
import reflex as rx
|
|
18
|
+
from ag_ui.core import RunAgentInput
|
|
19
|
+
|
|
20
|
+
from reflex_agentkit.core import commands
|
|
21
|
+
from reflex_agentkit.core._log import get_logger
|
|
22
|
+
from reflex_agentkit.core.clients.base import AgentClient, AgentSource, AGUIAgent
|
|
23
|
+
from reflex_agentkit.core.clients.in_process import InProcessAGUIClient
|
|
24
|
+
from reflex_agentkit.core.conversation import ConversationState
|
|
25
|
+
from reflex_agentkit.core.errors import ConfigurationError, ErrorCode
|
|
26
|
+
from reflex_agentkit.core.models import (
|
|
27
|
+
ChatMessage,
|
|
28
|
+
CustomEventRecord,
|
|
29
|
+
PendingInterrupt,
|
|
30
|
+
ReducerConfig,
|
|
31
|
+
RunStatus,
|
|
32
|
+
ToolCall,
|
|
33
|
+
ToolCallStatus,
|
|
34
|
+
)
|
|
35
|
+
from reflex_agentkit.core.tools import ClientTool
|
|
36
|
+
from reflex_agentkit.state._driver import DriverSettings, RunDriver, StateView
|
|
37
|
+
from reflex_agentkit.state._projection import project, refresh_dirty_args
|
|
38
|
+
from reflex_agentkit.state._registry import REGISTRY
|
|
39
|
+
|
|
40
|
+
__all__ = ["AgentState"]
|
|
41
|
+
|
|
42
|
+
_CONFIG_NAMES = (
|
|
43
|
+
"agent",
|
|
44
|
+
"client_tools",
|
|
45
|
+
"shared_state_model",
|
|
46
|
+
"flush_interval_ms",
|
|
47
|
+
"max_message_length",
|
|
48
|
+
"client_tool_timeout_s",
|
|
49
|
+
"custom_events_limit",
|
|
50
|
+
"max_client_tool_rounds",
|
|
51
|
+
)
|
|
52
|
+
_NOT_HANDLERS = (
|
|
53
|
+
"create_client",
|
|
54
|
+
"prepare_run_input",
|
|
55
|
+
"on_custom_event",
|
|
56
|
+
"on_run_end",
|
|
57
|
+
"typed_shared_state",
|
|
58
|
+
)
|
|
59
|
+
"""Public methods the browser must never call (TD §6.1, API §4.4)."""
|
|
60
|
+
_CAN_SEND = frozenset({RunStatus.IDLE, RunStatus.CANCELLED, RunStatus.ERROR})
|
|
61
|
+
_logger = get_logger("state.agent_state")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AgentState(rx.State, mixin=True):
|
|
65
|
+
"""Chat with an AG-UI agent from a Reflex app.
|
|
66
|
+
|
|
67
|
+
Use it as the first base of your state and set the configuration as ``ClassVar``::
|
|
68
|
+
|
|
69
|
+
class ChatState(AgentState, rx.State):
|
|
70
|
+
agent: ClassVar = my_agent
|
|
71
|
+
|
|
72
|
+
Without the ``ClassVar`` annotation Reflex would turn the attribute into a state var
|
|
73
|
+
(TD DD-006); ``AgentState`` reports that as a ``ConfigurationError``.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
# --- configuration (API §4.1): class-level, never a var --------------------------------
|
|
77
|
+
agent: ClassVar[AgentSource | None] = None
|
|
78
|
+
client_tools: ClassVar[Sequence[ClientTool]] = ()
|
|
79
|
+
shared_state_model: ClassVar[type[pydantic.BaseModel] | None] = None
|
|
80
|
+
flush_interval_ms: ClassVar[int] = 50
|
|
81
|
+
max_message_length: ClassVar[int] = 32_000
|
|
82
|
+
client_tool_timeout_s: ClassVar[float] = 30.0
|
|
83
|
+
custom_events_limit: ClassVar[int] = 100
|
|
84
|
+
max_client_tool_rounds: ClassVar[int] = 8
|
|
85
|
+
|
|
86
|
+
# --- public vars (API §4.2): a projection of _conv ------------------------------------
|
|
87
|
+
messages: list[ChatMessage] = [] # noqa: RUF012 - Reflex copies var defaults
|
|
88
|
+
tool_calls: list[ToolCall] = [] # noqa: RUF012
|
|
89
|
+
pending_interrupt: PendingInterrupt | None = None
|
|
90
|
+
shared_state: dict[str, Any] = {} # noqa: RUF012
|
|
91
|
+
shared_state_stale: bool = False
|
|
92
|
+
run_status: RunStatus = RunStatus.IDLE
|
|
93
|
+
current_step: str = ""
|
|
94
|
+
thread_id: str = ""
|
|
95
|
+
run_id: str = ""
|
|
96
|
+
error: str = ""
|
|
97
|
+
error_code: ErrorCode | None = None
|
|
98
|
+
custom_events: list[CustomEventRecord] = [] # noqa: RUF012
|
|
99
|
+
|
|
100
|
+
# --- backend-only vars (DD-008): never sent to the browser ----------------------------
|
|
101
|
+
_conv: ConversationState | None = None
|
|
102
|
+
_cancel_requested: str = ""
|
|
103
|
+
_pending_start: str = ""
|
|
104
|
+
|
|
105
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
106
|
+
# Validated before Reflex binds the class, so a bad class is never registered and
|
|
107
|
+
# an unannotated configuration attribute gets our message, not a VarTypeError.
|
|
108
|
+
# Reflex's metaclass consumes the ``mixin`` keyword and records it in ``_mixin``.
|
|
109
|
+
if not cls.__dict__.get("_mixin", False):
|
|
110
|
+
_validate(cls)
|
|
111
|
+
_hide_from_browser(cls)
|
|
112
|
+
super().__init_subclass__(**kwargs)
|
|
113
|
+
|
|
114
|
+
# --- computed vars (API §4.2) ----------------------------------------------------------
|
|
115
|
+
@rx.var
|
|
116
|
+
def is_running(self) -> bool:
|
|
117
|
+
"""Whether a run is streaming."""
|
|
118
|
+
return self.run_status == RunStatus.RUNNING
|
|
119
|
+
|
|
120
|
+
@rx.var
|
|
121
|
+
def awaiting_input(self) -> bool:
|
|
122
|
+
"""Whether the run is paused on approvals or interrupts."""
|
|
123
|
+
return self.run_status == RunStatus.AWAITING_INPUT
|
|
124
|
+
|
|
125
|
+
@rx.var
|
|
126
|
+
def pending_approvals(self) -> list[ToolCall]:
|
|
127
|
+
"""The calls waiting for ``approve_tool``/``reject_tool``, in start order."""
|
|
128
|
+
return [c for c in self.tool_calls if c.status == ToolCallStatus.PENDING_APPROVAL]
|
|
129
|
+
|
|
130
|
+
@rx.var
|
|
131
|
+
def can_send(self) -> bool:
|
|
132
|
+
"""Whether ``send_message`` would start a run."""
|
|
133
|
+
return self.run_status in _CAN_SEND
|
|
134
|
+
|
|
135
|
+
# --- hooks (API §4.4) ------------------------------------------------------------------
|
|
136
|
+
def create_client(self) -> AgentClient:
|
|
137
|
+
"""The client of one run. The default wraps ``agent``; override for per-session clients."""
|
|
138
|
+
agent = self.__class__.agent
|
|
139
|
+
if isinstance(agent, AgentClient):
|
|
140
|
+
return agent
|
|
141
|
+
if isinstance(agent, AGUIAgent):
|
|
142
|
+
return InProcessAGUIClient(agent)
|
|
143
|
+
raise ConfigurationError(f"{self.__class__.__name__}.agent is not set")
|
|
144
|
+
|
|
145
|
+
def prepare_run_input(self, run_input: RunAgentInput) -> RunAgentInput:
|
|
146
|
+
"""Adjust the input of each run (context, forwarded props, tools). Identity by default."""
|
|
147
|
+
return run_input
|
|
148
|
+
|
|
149
|
+
async def on_custom_event(self, name: str, value: Any) -> None:
|
|
150
|
+
"""React to a CUSTOM event, under the state lock. No-op by default (REQ-020)."""
|
|
151
|
+
|
|
152
|
+
async def on_run_end(self, status: RunStatus) -> None:
|
|
153
|
+
"""Called once per run after the final flush. No-op by default."""
|
|
154
|
+
|
|
155
|
+
# --- event handlers (API §4.3) ---------------------------------------------------------
|
|
156
|
+
# Every handler validates its arguments and preconditions: the browser can call any
|
|
157
|
+
# of them at any time with any payload (TD §6.1). Disallowed calls are no-ops.
|
|
158
|
+
|
|
159
|
+
@rx.event
|
|
160
|
+
def send_message(self, text: str) -> Any:
|
|
161
|
+
"""Append the user's message and start a run (REQ-001)."""
|
|
162
|
+
conv = self._conversation()
|
|
163
|
+
if not isinstance(text, str) or conv.run_status not in _CAN_SEND:
|
|
164
|
+
return None
|
|
165
|
+
text = text.strip()
|
|
166
|
+
if not text:
|
|
167
|
+
return None
|
|
168
|
+
limit = self.__class__.max_message_length
|
|
169
|
+
if len(text) > limit:
|
|
170
|
+
message = f"The message is longer than the limit of {limit} characters."
|
|
171
|
+
self._publish(conv.with_(error=message, error_code=ErrorCode.MESSAGE_TOO_LONG))
|
|
172
|
+
return None
|
|
173
|
+
return self._schedule(
|
|
174
|
+
commands.begin_run(conv, config=self.__class__._reducer_config(), user_text=text)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
@rx.event
|
|
178
|
+
def approve_tool(self, call_id: str, edited_args: dict[str, Any] | str | None = None) -> Any:
|
|
179
|
+
"""Approve a pending call, optionally with edited arguments (REQ-013.2-3)."""
|
|
180
|
+
if not isinstance(call_id, str) or not isinstance(edited_args, dict | str | None):
|
|
181
|
+
return None
|
|
182
|
+
return self._answered(commands.apply_approval(self._conversation(), call_id, edited_args))
|
|
183
|
+
|
|
184
|
+
@rx.event
|
|
185
|
+
def reject_tool(self, call_id: str, reason: str | None = None) -> Any:
|
|
186
|
+
"""Reject a pending call (REQ-013.4, REQ-018.3)."""
|
|
187
|
+
if not isinstance(call_id, str) or not isinstance(reason, str | None):
|
|
188
|
+
return None
|
|
189
|
+
if reason is not None and len(reason.encode()) > commands.MAX_PAYLOAD_BYTES:
|
|
190
|
+
return None
|
|
191
|
+
return self._answered(commands.apply_rejection(self._conversation(), call_id, reason))
|
|
192
|
+
|
|
193
|
+
@rx.event
|
|
194
|
+
def respond_to_interrupt(self, payload: Any) -> Any:
|
|
195
|
+
"""Answer the current generic interrupt (REQ-014.2-4)."""
|
|
196
|
+
return self._answered(commands.apply_interrupt_response(self._conversation(), payload))
|
|
197
|
+
|
|
198
|
+
@rx.event
|
|
199
|
+
def cancel_run(self) -> None:
|
|
200
|
+
"""Stop the run, or leave the pause (REQ-007.1, REQ-015.2)."""
|
|
201
|
+
conv = self._conversation()
|
|
202
|
+
if conv.run_status is RunStatus.RUNNING:
|
|
203
|
+
self._request_cancel(conv.run_id)
|
|
204
|
+
elif conv.run_status is RunStatus.AWAITING_INPUT:
|
|
205
|
+
self._publish(commands.apply_cancel(conv))
|
|
206
|
+
|
|
207
|
+
@rx.event
|
|
208
|
+
def new_thread(self) -> None:
|
|
209
|
+
"""Start a new conversation, cancelling a running run first (REQ-002.3-4)."""
|
|
210
|
+
conv = self._conversation()
|
|
211
|
+
if conv.run_status is RunStatus.RUNNING:
|
|
212
|
+
self._request_cancel(conv.run_id)
|
|
213
|
+
self._pending_start = ""
|
|
214
|
+
self._publish(commands.new_thread(conv))
|
|
215
|
+
|
|
216
|
+
@rx.event
|
|
217
|
+
def set_shared_state(self, value: dict[str, Any]) -> None:
|
|
218
|
+
"""Replace the shared state between runs (REQ-017)."""
|
|
219
|
+
conv = self._conversation()
|
|
220
|
+
self._publish(self._validated(conv, commands.apply_shared_state_write(conv, value)))
|
|
221
|
+
|
|
222
|
+
@rx.event
|
|
223
|
+
def patch_shared_state(self, ops: list[dict[str, Any]]) -> None:
|
|
224
|
+
"""Apply RFC 6902 operations to the shared state between runs (REQ-017)."""
|
|
225
|
+
conv = self._conversation()
|
|
226
|
+
self._publish(self._validated(conv, commands.apply_shared_state_patch(conv, ops)))
|
|
227
|
+
|
|
228
|
+
@rx.event
|
|
229
|
+
def retry_run(self) -> Any:
|
|
230
|
+
"""Run the last user turn again after an error or a cancel (REQ-028)."""
|
|
231
|
+
conv = self._conversation()
|
|
232
|
+
retried = commands.begin_retry(conv, config=self.__class__._reducer_config())
|
|
233
|
+
if retried is conv:
|
|
234
|
+
return None
|
|
235
|
+
return self._schedule(retried)
|
|
236
|
+
|
|
237
|
+
@rx.event(background=True)
|
|
238
|
+
async def drive_run(self) -> None:
|
|
239
|
+
"""Stream the scheduled run (TD Flow A.3). A no-op unless it claims the start (§6.1)."""
|
|
240
|
+
try:
|
|
241
|
+
async with self:
|
|
242
|
+
conv = self._claim()
|
|
243
|
+
if conv is None:
|
|
244
|
+
return
|
|
245
|
+
token = self._client_token()
|
|
246
|
+
driver = RunDriver(
|
|
247
|
+
_ReflexStateAccess(self),
|
|
248
|
+
client_factory=self.create_client,
|
|
249
|
+
settings=self.__class__._driver_settings(),
|
|
250
|
+
prepare_input=self.prepare_run_input,
|
|
251
|
+
on_flush=self._dispatch_custom_events,
|
|
252
|
+
on_run_end=self.on_run_end,
|
|
253
|
+
registry=REGISTRY,
|
|
254
|
+
client_token=token,
|
|
255
|
+
)
|
|
256
|
+
await driver.run(conv)
|
|
257
|
+
except Exception as exc: # nothing escapes drive_run (TD §5.3)
|
|
258
|
+
_logger.error("drive_run failed: %s", type(exc).__name__)
|
|
259
|
+
_logger.debug("drive_run traceback", exc_info=exc)
|
|
260
|
+
|
|
261
|
+
def typed_shared_state(self) -> pydantic.BaseModel:
|
|
262
|
+
"""``shared_state`` as an instance of ``shared_state_model`` (REQ-016.6)."""
|
|
263
|
+
model = self.__class__.shared_state_model
|
|
264
|
+
if model is None:
|
|
265
|
+
raise ConfigurationError(f"{self.__class__.__name__}.shared_state_model is not set")
|
|
266
|
+
return model.model_validate(self.shared_state)
|
|
267
|
+
|
|
268
|
+
# --- internals -------------------------------------------------------------------------
|
|
269
|
+
def _schedule(self, conv: ConversationState) -> Any:
|
|
270
|
+
"""Store a started run and return the background event that streams it (Flow A.2)."""
|
|
271
|
+
if conv.run_status is not RunStatus.RUNNING:
|
|
272
|
+
self._publish(conv)
|
|
273
|
+
return None
|
|
274
|
+
self._cancel_requested = ""
|
|
275
|
+
self._pending_start = conv.run_id
|
|
276
|
+
self._publish(conv)
|
|
277
|
+
return self.__class__.drive_run
|
|
278
|
+
|
|
279
|
+
def _answered(self, conv: ConversationState) -> Any:
|
|
280
|
+
"""Store an answer; start the resume run once every interrupt has one (Flow C.3)."""
|
|
281
|
+
if conv is self._conv:
|
|
282
|
+
return None
|
|
283
|
+
if commands.all_answered(conv):
|
|
284
|
+
return self._schedule(
|
|
285
|
+
commands.begin_run(conv, config=self.__class__._reducer_config(), resume=True)
|
|
286
|
+
)
|
|
287
|
+
self._publish(conv)
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
def _request_cancel(self, run_id: str) -> None:
|
|
291
|
+
"""Flow D.1: flag the run, and cancel its task if it lives in this worker."""
|
|
292
|
+
self._cancel_requested = run_id
|
|
293
|
+
REGISTRY.cancel(self._client_token(), run_id)
|
|
294
|
+
|
|
295
|
+
def _claim(self) -> ConversationState | None:
|
|
296
|
+
"""Flow A.3: take the scheduled start, once. Must run under the state lock."""
|
|
297
|
+
conv = self._conversation()
|
|
298
|
+
if not self._pending_start or self._pending_start != conv.run_id:
|
|
299
|
+
return None
|
|
300
|
+
self._pending_start = ""
|
|
301
|
+
if conv.run_status is not RunStatus.RUNNING:
|
|
302
|
+
return None
|
|
303
|
+
if self._cancel_requested == conv.run_id:
|
|
304
|
+
self._publish(commands.apply_cancel(conv))
|
|
305
|
+
return None
|
|
306
|
+
return conv
|
|
307
|
+
|
|
308
|
+
def _validated(self, before: ConversationState, after: ConversationState) -> ConversationState:
|
|
309
|
+
"""Reject an app write that fails ``shared_state_model`` (REQ-016.5)."""
|
|
310
|
+
if after.shared_state is before.shared_state or _valid_shared_state(
|
|
311
|
+
self.__class__, after.shared_state
|
|
312
|
+
):
|
|
313
|
+
return after
|
|
314
|
+
return before.with_(
|
|
315
|
+
error="The shared state does not match shared_state_model.",
|
|
316
|
+
error_code=ErrorCode.SHARED_STATE_INVALID,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
async def _dispatch_custom_events(self, records: list[CustomEventRecord]) -> None:
|
|
320
|
+
"""Run ``on_custom_event`` for each record, under the lock (REQ-020)."""
|
|
321
|
+
for record in records:
|
|
322
|
+
try:
|
|
323
|
+
await self.on_custom_event(record.name, record.value)
|
|
324
|
+
except Exception as exc:
|
|
325
|
+
_logger.warning("on_custom_event raised %s for %s", type(exc).__name__, record.name)
|
|
326
|
+
_logger.debug("on_custom_event traceback", exc_info=exc)
|
|
327
|
+
|
|
328
|
+
@classmethod
|
|
329
|
+
def _driver_settings(cls) -> DriverSettings:
|
|
330
|
+
validate = None
|
|
331
|
+
if cls.shared_state_model is not None:
|
|
332
|
+
|
|
333
|
+
def validate(value: dict[str, Any]) -> bool:
|
|
334
|
+
return _valid_shared_state(cls, value)
|
|
335
|
+
|
|
336
|
+
return DriverSettings(
|
|
337
|
+
reducer_config=cls._reducer_config(),
|
|
338
|
+
flush_interval_ms=cls.flush_interval_ms,
|
|
339
|
+
client_tools=tuple(cls.client_tools),
|
|
340
|
+
client_tool_timeout_s=cls.client_tool_timeout_s,
|
|
341
|
+
max_client_tool_rounds=cls.max_client_tool_rounds,
|
|
342
|
+
validate_shared_state=validate,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def _reducer_config(cls) -> ReducerConfig:
|
|
347
|
+
return ReducerConfig(
|
|
348
|
+
client_tool_names=frozenset(t.name for t in cls.client_tools),
|
|
349
|
+
custom_events_limit=cls.custom_events_limit,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def _conversation(self) -> ConversationState:
|
|
353
|
+
"""The stored conversation, created on first use.
|
|
354
|
+
|
|
355
|
+
Read through a background handler's ``StateProxy``, Reflex wraps the value in a
|
|
356
|
+
mutable proxy whose fields (``shared_state``) are proxies too; those cannot be
|
|
357
|
+
serialized into ``RunAgentInput``. The conversation is immutable, so the plain
|
|
358
|
+
object is returned.
|
|
359
|
+
"""
|
|
360
|
+
if self._conv is None:
|
|
361
|
+
self._conv = ConversationState.initial(self.__class__._reducer_config())
|
|
362
|
+
conv: ConversationState = getattr(self._conv, "__wrapped__", self._conv)
|
|
363
|
+
return conv
|
|
364
|
+
|
|
365
|
+
def _publish(self, conv: ConversationState) -> None:
|
|
366
|
+
"""Store ``conv`` and project it onto the public vars (Flow A.7)."""
|
|
367
|
+
conv = refresh_dirty_args(conv)
|
|
368
|
+
self._conv = conv
|
|
369
|
+
view = project(conv)
|
|
370
|
+
self.messages = view.messages
|
|
371
|
+
self.tool_calls = view.tool_calls
|
|
372
|
+
self.pending_interrupt = view.pending_interrupt
|
|
373
|
+
self.shared_state = view.shared_state
|
|
374
|
+
self.shared_state_stale = view.shared_state_stale
|
|
375
|
+
self.run_status = view.run_status
|
|
376
|
+
self.current_step = view.current_step
|
|
377
|
+
self.thread_id = view.thread_id
|
|
378
|
+
self.run_id = view.run_id
|
|
379
|
+
self.error = view.error
|
|
380
|
+
self.error_code = view.error_code
|
|
381
|
+
self.custom_events = view.custom_events
|
|
382
|
+
|
|
383
|
+
def _client_token(self) -> str:
|
|
384
|
+
return str(self.router.session.client_token)
|
|
385
|
+
|
|
386
|
+
def _view(self) -> StateView:
|
|
387
|
+
return StateView(
|
|
388
|
+
conv=self._conversation(),
|
|
389
|
+
cancel_requested=self._cancel_requested,
|
|
390
|
+
client_token=self._client_token(),
|
|
391
|
+
publish=self._publish,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _valid_shared_state(cls: type[AgentState], value: dict[str, Any]) -> bool:
|
|
396
|
+
model = cls.shared_state_model
|
|
397
|
+
if model is None:
|
|
398
|
+
return True
|
|
399
|
+
try:
|
|
400
|
+
model.model_validate(value)
|
|
401
|
+
except pydantic.ValidationError:
|
|
402
|
+
return False
|
|
403
|
+
return True
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class _ReflexStateAccess:
|
|
407
|
+
"""The driver's ``StateAccess`` over a background handler's ``async with self``."""
|
|
408
|
+
|
|
409
|
+
def __init__(self, state: AgentState) -> None:
|
|
410
|
+
self._state = state
|
|
411
|
+
|
|
412
|
+
@asynccontextmanager
|
|
413
|
+
async def locked(self) -> AsyncIterator[StateView]:
|
|
414
|
+
async with self._state:
|
|
415
|
+
yield self._state._view()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# --- configuration checks (API §4.1) --------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _is_class_var(annotation: object) -> bool:
|
|
422
|
+
if isinstance(annotation, str):
|
|
423
|
+
return annotation.split("[", 1)[0].strip() in {"ClassVar", "typing.ClassVar"}
|
|
424
|
+
return annotation is ClassVar or typing.get_origin(annotation) is ClassVar
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _hide_from_browser(cls: type) -> None:
|
|
428
|
+
"""Keep the hooks defined in ``cls`` out of Reflex's event-handler registration.
|
|
429
|
+
|
|
430
|
+
Reflex registers every public method of a state as an event handler the browser can
|
|
431
|
+
call with any arguments, and skips functions flagged ``__override_base_method__``
|
|
432
|
+
(TD §6.1). The flag goes on ``AgentState``'s own hooks and on every override found
|
|
433
|
+
along the MRO, including overrides from Reflex mixins that are not ``AgentState``s
|
|
434
|
+
(Reflex copies a mixin's handlers onto each class that uses it).
|
|
435
|
+
"""
|
|
436
|
+
for base in cls.__mro__:
|
|
437
|
+
for name in _NOT_HANDLERS:
|
|
438
|
+
fn = base.__dict__.get(name)
|
|
439
|
+
if hasattr(fn, "__code__"):
|
|
440
|
+
fn.__override_base_method__ = True # type: ignore[union-attr]
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _validate(cls: type[AgentState]) -> None:
|
|
444
|
+
"""Raise ``ConfigurationError`` naming the attribute (API §4.1, DD-006)."""
|
|
445
|
+
name = cls.__name__
|
|
446
|
+
annotations = inspect.get_annotations(cls)
|
|
447
|
+
for attr in _CONFIG_NAMES:
|
|
448
|
+
if attr in cls.__dict__ and not _is_class_var(annotations.get(attr)):
|
|
449
|
+
raise ConfigurationError(
|
|
450
|
+
f"{name}.{attr} must be annotated as ClassVar "
|
|
451
|
+
f"(`{attr}: ClassVar = ...`), or Reflex makes it a state var"
|
|
452
|
+
)
|
|
453
|
+
overrides_client = cls.create_client is not AgentState.create_client
|
|
454
|
+
agent = cls.agent
|
|
455
|
+
if agent is None:
|
|
456
|
+
if not overrides_client:
|
|
457
|
+
raise ConfigurationError(f"{name}.agent is required unless create_client is overridden")
|
|
458
|
+
elif not isinstance(agent, AgentClient | AGUIAgent):
|
|
459
|
+
raise ConfigurationError(
|
|
460
|
+
f"{name}.agent must be an AgentClient or an AG-UI agent, not {type(agent).__name__}"
|
|
461
|
+
)
|
|
462
|
+
_check_int(name, "flush_interval_ms", cls.flush_interval_ms, minimum=0)
|
|
463
|
+
_check_int(name, "max_message_length", cls.max_message_length, minimum=1)
|
|
464
|
+
_check_int(name, "custom_events_limit", cls.custom_events_limit, minimum=1)
|
|
465
|
+
_check_int(name, "max_client_tool_rounds", cls.max_client_tool_rounds, minimum=1)
|
|
466
|
+
timeout = cls.client_tool_timeout_s
|
|
467
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int | float) or timeout <= 0:
|
|
468
|
+
raise ConfigurationError(f"{name}.client_tool_timeout_s must be a number > 0")
|
|
469
|
+
_check_tools(name, cls.client_tools)
|
|
470
|
+
model = cls.shared_state_model
|
|
471
|
+
if model is not None and not (
|
|
472
|
+
isinstance(model, type) and issubclass(model, pydantic.BaseModel)
|
|
473
|
+
):
|
|
474
|
+
raise ConfigurationError(f"{name}.shared_state_model must be a pydantic.BaseModel subclass")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _check_int(name: str, attr: str, value: object, *, minimum: int) -> None:
|
|
478
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
479
|
+
raise ConfigurationError(f"{name}.{attr} must be an int >= {minimum}")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _check_tools(name: str, tools: object) -> None:
|
|
483
|
+
if isinstance(tools, str | bytes) or not isinstance(tools, Sequence):
|
|
484
|
+
raise ConfigurationError(f"{name}.client_tools must be a sequence of client tools")
|
|
485
|
+
seen: set[str] = set()
|
|
486
|
+
for tool in tools:
|
|
487
|
+
if not isinstance(tool, ClientTool):
|
|
488
|
+
raise ConfigurationError(
|
|
489
|
+
f"{name}.client_tools must hold @client_tool objects, not {type(tool).__name__}"
|
|
490
|
+
)
|
|
491
|
+
if tool.name in seen:
|
|
492
|
+
raise ConfigurationError(f"{name}.client_tools has two tools named {tool.name!r}")
|
|
493
|
+
seen.add(tool.name)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
_hide_from_browser(AgentState)
|