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