reflex-agentkit 0.1.0rc1__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 +1065 -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.0rc1.dist-info/METADATA +457 -0
  36. reflex_agentkit-0.1.0rc1.dist-info/RECORD +38 -0
  37. reflex_agentkit-0.1.0rc1.dist-info/WHEEL +4 -0
  38. reflex_agentkit-0.1.0rc1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,68 @@
1
+ """reflex-agentkit: the production UI layer for Python AI agents, built on Reflex and AG-UI.
2
+
3
+ Reflex-dependent names (``AgentState``, components) are re-exported lazily through a
4
+ module-level ``__getattr__`` (PEP 562), so ``import reflex_agentkit.core`` never imports
5
+ ``reflex`` (API §1, Constitution Art. 5).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from reflex_agentkit.core import (
14
+ AgentClient,
15
+ AgentKitError,
16
+ AgentSource,
17
+ AGUIAgent,
18
+ ClientTool,
19
+ ConfigurationError,
20
+ ErrorCode,
21
+ HttpAGUIClient,
22
+ InProcessAGUIClient,
23
+ RunStatus,
24
+ ToolCallStatus,
25
+ client_tool,
26
+ )
27
+
28
+ if TYPE_CHECKING: # type checkers see the lazy names; runtime imports them on first use
29
+ from reflex_agentkit.components.chat import agent_chat as agent_chat
30
+ from reflex_agentkit.state.agent_state import AgentState as AgentState
31
+
32
+ __version__ = "0.1.0rc1"
33
+
34
+ # Public name -> module that defines it (API §1, §3).
35
+ _LAZY_EXPORTS: dict[str, str] = {
36
+ "AgentState": "reflex_agentkit.state.agent_state",
37
+ "agent_chat": "reflex_agentkit.components.chat",
38
+ }
39
+
40
+ __all__ = [
41
+ "AGUIAgent",
42
+ "AgentClient",
43
+ "AgentKitError",
44
+ "AgentSource",
45
+ "ClientTool",
46
+ "ConfigurationError",
47
+ "ErrorCode",
48
+ "HttpAGUIClient",
49
+ "InProcessAGUIClient",
50
+ "RunStatus",
51
+ "ToolCallStatus",
52
+ "__version__",
53
+ "client_tool",
54
+ *_LAZY_EXPORTS,
55
+ ]
56
+
57
+
58
+ def __getattr__(name: str) -> Any:
59
+ module_name = _LAZY_EXPORTS.get(name)
60
+ if module_name is None:
61
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
62
+ value = getattr(importlib.import_module(module_name), name)
63
+ globals()[name] = value
64
+ return value
65
+
66
+
67
+ def __dir__() -> list[str]:
68
+ return sorted({*globals(), *_LAZY_EXPORTS})
@@ -0,0 +1 @@
1
+ """Optional framework adapters that translate into AG-UI."""
@@ -0,0 +1,27 @@
1
+ """Import guard for the optional adapters (REQ-031.3, API §7.7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+
7
+ from reflex_agentkit.core.errors import AdapterNotInstalledError
8
+
9
+ __all__ = ["require"]
10
+
11
+
12
+ def require(extra: str, *modules: str) -> None:
13
+ """Import ``modules`` or raise ``AdapterNotInstalledError`` naming the extra to install.
14
+
15
+ Args:
16
+ extra: The package extra, e.g. ``"pydantic-ai"``; the adapter module is
17
+ ``reflex_agentkit.adapters.<extra with "_" for "-">``.
18
+ modules: The modules the adapter needs.
19
+ """
20
+ adapter = f"reflex_agentkit.adapters.{extra.replace('-', '_')}"
21
+ for module in modules:
22
+ try:
23
+ importlib.import_module(module)
24
+ except ImportError:
25
+ raise AdapterNotInstalledError(
26
+ f"{adapter} requires the '{extra}' extra: pip install 'reflex-agentkit[{extra}]'"
27
+ ) from None
@@ -0,0 +1,277 @@
1
+ """LangGraph → AG-UI adapter (API §7.6, TD DD-012). Needs ``reflex-agentkit[langgraph]``.
2
+
3
+ ``LangGraphAGUIAgent`` subclasses the official ``ag_ui_langgraph.LangGraphAgent`` with
4
+ AG-UI standard interrupts on. ``request_tool_approval`` is called inside a graph node or
5
+ tool to pause for a human decision on a tool call, with the same payload contract as the
6
+ PydanticAI adapter (API §7.4).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from reflex_agentkit.adapters._require import require
12
+
13
+ require("langgraph", "langgraph", "ag_ui_langgraph")
14
+
15
+ import dataclasses # noqa: E402 - the guard must run before the optional imports
16
+ import json # noqa: E402
17
+ import logging # noqa: E402
18
+ from collections.abc import AsyncIterator, Mapping # noqa: E402
19
+ from typing import Any, Self, cast # noqa: E402
20
+
21
+ from ag_ui.core import ( # noqa: E402
22
+ BaseEvent,
23
+ CustomEvent,
24
+ Interrupt,
25
+ ResumeEntry,
26
+ RunAgentInput,
27
+ RunFinishedEvent,
28
+ RunFinishedInterruptOutcome,
29
+ )
30
+ from ag_ui_langgraph import LangGraphAgent # noqa: E402
31
+ from ag_ui_langgraph.interrupts import ( # noqa: E402
32
+ DEFAULT_RESUME_SENTINEL_CANCELLED,
33
+ lg_interrupt_to_agui,
34
+ )
35
+ from langchain_core.runnables import RunnableConfig # noqa: E402
36
+ from langgraph.graph.state import CompiledStateGraph # noqa: E402
37
+ from langgraph.types import Command, interrupt # noqa: E402
38
+ from langgraph.types import Interrupt as LangGraphInterrupt # noqa: E402
39
+
40
+ from reflex_agentkit.core.errors import ConfigurationError # noqa: E402
41
+
42
+ __all__ = ["ApprovalDecision", "LangGraphAGUIAgent", "request_tool_approval"]
43
+
44
+ APPROVAL_REASON = "tool_call"
45
+ """The interrupt reason that marks a tool approval (API §7.6)."""
46
+
47
+ APPROVAL_RESPONSE_SCHEMA: dict[str, Any] = {
48
+ "type": "object",
49
+ "properties": {
50
+ "approved": {"type": "boolean"},
51
+ "editedArgs": {"type": "object"},
52
+ "reason": {"type": "string"},
53
+ },
54
+ "required": ["approved"],
55
+ }
56
+ """The JSON Schema of an approval answer; the same one PydanticAI sends (API §7.4)."""
57
+
58
+ CANCELLED_APPROVAL: dict[str, Any] = {"approved": False, "reason": "cancelled"}
59
+ """What a cancelled tool approval resumes with (DD-012)."""
60
+
61
+ _HIDDEN_SNAPSHOT_KEYS = frozenset({"messages", "tools"})
62
+ _LEGACY_EVENT = "on_interrupt"
63
+ _LEGACY_PREFIX = "legacy-"
64
+ _LEGACY_TOOL_PREFIX = "legacy-tool-"
65
+
66
+ _log = logging.getLogger(__name__)
67
+
68
+
69
+ @dataclasses.dataclass(frozen=True, slots=True)
70
+ class ApprovalDecision:
71
+ """A human decision on a tool call (API §7.6).
72
+
73
+ Attributes:
74
+ approved: Whether the tool may run.
75
+ args: The arguments to run it with: the edited ones when the user edited them,
76
+ otherwise the original ones.
77
+ reason: The rejection reason, ``"cancelled"`` when the run was cancelled while
78
+ waiting, or ``None``.
79
+ """
80
+
81
+ approved: bool
82
+ args: dict[str, Any]
83
+ reason: str | None = None
84
+
85
+
86
+ def request_tool_approval(
87
+ tool_call_id: str, tool_name: str, args: dict[str, Any], *, message: str | None = None
88
+ ) -> ApprovalDecision:
89
+ """Pause the graph until the user approves, edits or rejects a tool call (REQ-034.3).
90
+
91
+ Call it inside a graph node or tool. It raises LangGraph's ``interrupt`` with the
92
+ keys that ag-ui-langgraph maps onto the AG-UI ``Interrupt``, so the call shows as a
93
+ ``pending_approval`` tool card. When the graph resumes, it returns the decision.
94
+
95
+ Args:
96
+ tool_call_id: The ID of the tool call in the assistant message.
97
+ tool_name: The tool's name.
98
+ args: The tool call's arguments.
99
+ message: The question shown to the user; defaults to ``"Approve <tool_name>?"``.
100
+ """
101
+ answer = interrupt(
102
+ {
103
+ "reason": APPROVAL_REASON,
104
+ "toolCallId": tool_call_id,
105
+ "toolName": tool_name,
106
+ "args": args,
107
+ "message": message if message is not None else f"Approve {tool_name}?",
108
+ "responseSchema": APPROVAL_RESPONSE_SCHEMA,
109
+ }
110
+ )
111
+ return _decision(answer, args)
112
+
113
+
114
+ def _decision(answer: Any, args: dict[str, Any]) -> ApprovalDecision:
115
+ """Read the API §7.4 payload. Anything but ``approved: true`` is a rejection."""
116
+ if not isinstance(answer, Mapping):
117
+ return ApprovalDecision(approved=False, args=args)
118
+ reason = answer.get("reason")
119
+ reason = reason if isinstance(reason, str) else None
120
+ if answer.get("approved") is not True:
121
+ return ApprovalDecision(approved=False, args=args, reason=reason)
122
+ edited = answer.get("editedArgs")
123
+ return ApprovalDecision(
124
+ approved=True, args=dict(edited) if isinstance(edited, Mapping) else args
125
+ )
126
+
127
+
128
+ class LangGraphAGUIAgent(LangGraphAgent): # type: ignore[misc] # untyped upstream base
129
+ """Run a compiled LangGraph graph as an ``AGUIAgent`` (REQ-034.1).
130
+
131
+ One instance can serve every session: each ``run()`` works on a fresh clone, because
132
+ the upstream agent keeps per-run state on the instance (DD-012).
133
+
134
+ Args:
135
+ graph: A compiled graph with a checkpointer (REQ-034.4).
136
+ name: The agent name reported to AG-UI.
137
+ config: The base ``RunnableConfig`` for every run.
138
+ legacy_interrupts: Read interrupts from the legacy ``on_interrupt`` CUSTOM event
139
+ and resume through ``forwarded_props`` (REQ-034.5, compatibility mode).
140
+
141
+ Raises:
142
+ ConfigurationError: ``graph`` is not a compiled graph, or it has no checkpointer.
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ graph: CompiledStateGraph[Any, Any, Any, Any],
148
+ *,
149
+ name: str = "agent",
150
+ config: RunnableConfig | None = None,
151
+ legacy_interrupts: bool = False,
152
+ ) -> None:
153
+ if not isinstance(graph, CompiledStateGraph):
154
+ raise ConfigurationError(
155
+ f"LangGraphAGUIAgent needs a compiled graph, not {type(graph).__name__}"
156
+ )
157
+ if graph.checkpointer is None:
158
+ raise ConfigurationError(
159
+ "LangGraphAGUIAgent needs a graph compiled with a checkpointer, "
160
+ "for example graph.compile(checkpointer=InMemorySaver())"
161
+ )
162
+ self.legacy_interrupts = legacy_interrupts
163
+ super().__init__(
164
+ name=name,
165
+ graph=graph,
166
+ config=config,
167
+ emit_interrupt_outcome=not legacy_interrupts,
168
+ enable_legacy_on_interrupt_event=legacy_interrupts,
169
+ emit_raw_events=False,
170
+ )
171
+
172
+ def __repr__(self) -> str:
173
+ return f"LangGraphAGUIAgent(name={self.name!r})"
174
+
175
+ def clone(self) -> Self:
176
+ """A fresh copy with clean per-run state and the same settings."""
177
+ return type(self)(
178
+ self.graph,
179
+ name=self.name,
180
+ config=cast(RunnableConfig, dict(self.config)) if self.config else None,
181
+ legacy_interrupts=self.legacy_interrupts,
182
+ )
183
+
184
+ async def run(self, input: RunAgentInput) -> AsyncIterator[BaseEvent]:
185
+ """Stream one run as AG-UI events on a fresh clone (API §7.1)."""
186
+ worker = self.clone()
187
+ if not self.legacy_interrupts:
188
+ async for event in LangGraphAgent.run(worker, input):
189
+ yield event
190
+ return
191
+ legacy_values: list[Any] = []
192
+ async for event in LangGraphAgent.run(worker, _legacy_input(input)):
193
+ if isinstance(event, CustomEvent) and event.name == _LEGACY_EVENT:
194
+ legacy_values.append(_decode(event.value))
195
+ continue
196
+ if isinstance(event, RunFinishedEvent) and event.outcome is None and legacy_values:
197
+ event = _legacy_finish(event, legacy_values)
198
+ yield event
199
+
200
+ def get_state_snapshot(self, state: Any) -> Any:
201
+ """The upstream snapshot without ``messages`` and ``tools`` (DD-012)."""
202
+ snapshot = super().get_state_snapshot(state)
203
+ if not isinstance(snapshot, Mapping):
204
+ return snapshot
205
+ return {k: v for k, v in snapshot.items() if k not in _HIDDEN_SNAPSHOT_KEYS}
206
+
207
+ def _build_command_from_agui_resume(
208
+ self,
209
+ entries: list[ResumeEntry],
210
+ *,
211
+ open_interrupts: list[Interrupt] | None = None,
212
+ ) -> Command[Any]:
213
+ """Native LangGraph resumes (DD-012).
214
+
215
+ One entry resumes with its payload; several use ``{interrupt_id: payload}`` so each
216
+ interrupted node gets its own answer. A cancelled tool approval resumes as a
217
+ rejection with reason ``"cancelled"``; a cancelled generic interrupt keeps the
218
+ upstream ``__agui_cancelled__`` sentinel.
219
+ """
220
+ reasons = {i.id: i.reason for i in open_interrupts or ()}
221
+
222
+ def answer(entry: ResumeEntry) -> Any:
223
+ if entry.status == "resolved":
224
+ return entry.payload
225
+ is_approval = reasons.get(entry.interrupt_id) == APPROVAL_REASON
226
+ return _cancelled_answer(entry.interrupt_id, is_approval=is_approval)
227
+
228
+ if len(entries) == 1:
229
+ return Command(resume=answer(entries[0]))
230
+ return Command(resume={entry.interrupt_id: answer(entry) for entry in entries})
231
+
232
+
233
+ # --- legacy interrupts (REQ-034.5, DD-012) ---------------------------------------------------
234
+
235
+
236
+ def _cancelled_answer(interrupt_id: str, *, is_approval: bool) -> Any:
237
+ if is_approval:
238
+ return dict(CANCELLED_APPROVAL)
239
+ return {DEFAULT_RESUME_SENTINEL_CANCELLED: True, "interrupt_id": interrupt_id}
240
+
241
+
242
+ def _legacy_input(run_input: RunAgentInput) -> RunAgentInput:
243
+ """Move a ``resume[]`` answer to ``forwarded_props.command.resume`` (DD-012)."""
244
+ if not run_input.resume:
245
+ return run_input
246
+ entry = run_input.resume[0]
247
+ if entry.status == "resolved":
248
+ answer = entry.payload
249
+ else:
250
+ is_approval = entry.interrupt_id.startswith(_LEGACY_TOOL_PREFIX)
251
+ answer = _cancelled_answer(entry.interrupt_id, is_approval=is_approval)
252
+ forwarded = dict(run_input.forwarded_props or {})
253
+ forwarded["command"] = {"resume": answer}
254
+ return run_input.model_copy(update={"resume": None, "forwarded_props": forwarded})
255
+
256
+
257
+ def _decode(value: Any) -> Any:
258
+ """The legacy event carries a dict value as JSON text and a string as itself."""
259
+ if not isinstance(value, str):
260
+ return value
261
+ try:
262
+ return json.loads(value)
263
+ except ValueError:
264
+ return value
265
+
266
+
267
+ def _legacy_finish(event: RunFinishedEvent, values: list[Any]) -> RunFinishedEvent:
268
+ """``RUN_FINISHED`` with the first legacy interrupt as a standard outcome."""
269
+ if len(values) > 1:
270
+ _log.warning("legacy interrupts: dropped %d extra interrupt(s)", len(values) - 1)
271
+ value = values[0]
272
+ is_approval = isinstance(value, Mapping) and value.get("reason") == APPROVAL_REASON
273
+ prefix = _LEGACY_TOOL_PREFIX if is_approval else _LEGACY_PREFIX
274
+ mapped = lg_interrupt_to_agui(LangGraphInterrupt(value=value, id=f"{prefix}{event.run_id}"))
275
+ mapped = mapped.model_copy(update={"metadata": {"langgraph": {"raw": value}}})
276
+ outcome = RunFinishedInterruptOutcome(type="interrupt", interrupts=[mapped])
277
+ return event.model_copy(update={"outcome": outcome})
@@ -0,0 +1,93 @@
1
+ """PydanticAI → AG-UI adapter (API §7.5, TD DD-011). Needs ``reflex-agentkit[pydantic-ai]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from reflex_agentkit.adapters._require import require
6
+
7
+ require("pydantic-ai", "pydantic_ai", "pydantic_ai.ui.ag_ui")
8
+
9
+ import inspect # noqa: E402 - the guard must run before the optional imports
10
+ from collections.abc import AsyncIterator, Awaitable, Callable, Sequence # noqa: E402
11
+ from typing import Any # noqa: E402
12
+
13
+ from ag_ui.core import BaseEvent, RunAgentInput # noqa: E402
14
+ from pydantic_ai import DeferredToolRequests # noqa: E402
15
+ from pydantic_ai.agent import AbstractAgent # noqa: E402
16
+ from pydantic_ai.ui.ag_ui import AGUIAdapter # noqa: E402
17
+
18
+ from reflex_agentkit.core.errors import ConfigurationError # noqa: E402
19
+
20
+ __all__ = ["PydanticAIAgent"]
21
+
22
+ DepsFactory = Callable[[RunAgentInput], Any | Awaitable[Any]]
23
+
24
+
25
+ class PydanticAIAgent:
26
+ """Run a PydanticAI agent as an ``AGUIAgent`` (REQ-022.1).
27
+
28
+ Approval-required tools pause with an AG-UI interrupt with no configuration: when
29
+ the agent's ``output_type`` lacks ``DeferredToolRequests`` it is added for the run
30
+ (DD-011). Resumes follow PydanticAI's native payload contract (API §7.4).
31
+
32
+ Args:
33
+ agent: The PydanticAI agent.
34
+ deps: Dependencies passed to every run.
35
+ deps_factory: Builds the dependencies of one run from its input (sync or async).
36
+ Mutually exclusive with ``deps``.
37
+ run_kwargs: Forwarded to ``AGUIAdapter.run_stream`` (``model_settings``,
38
+ ``usage_limits``, ``model``, ...).
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ agent: AbstractAgent[Any, Any],
44
+ *,
45
+ deps: Any = None,
46
+ deps_factory: DepsFactory | None = None,
47
+ **run_kwargs: Any,
48
+ ) -> None:
49
+ if not isinstance(agent, AbstractAgent):
50
+ raise ConfigurationError(
51
+ f"PydanticAIAgent needs a pydantic_ai Agent, not {type(agent).__name__}"
52
+ )
53
+ if deps is not None and deps_factory is not None:
54
+ raise ConfigurationError("PydanticAIAgent takes deps or deps_factory, not both")
55
+ self._agent = agent
56
+ self._deps = deps
57
+ self._deps_factory = deps_factory
58
+ self._run_kwargs = run_kwargs
59
+ self._output_type = _with_deferred(agent.output_type)
60
+
61
+ def __repr__(self) -> str:
62
+ return f"PydanticAIAgent({self._agent.name or type(self._agent).__name__!s})"
63
+
64
+ async def run(self, run_input: RunAgentInput) -> AsyncIterator[BaseEvent]:
65
+ """Stream one run as AG-UI events (API §7.1)."""
66
+ deps = self._deps
67
+ if self._deps_factory is not None:
68
+ deps = self._deps_factory(run_input)
69
+ if inspect.isawaitable(deps):
70
+ deps = await deps
71
+ kwargs = dict(self._run_kwargs)
72
+ if self._output_type is not None:
73
+ kwargs.setdefault("output_type", self._output_type)
74
+ adapter = AGUIAdapter(agent=self._agent, run_input=run_input)
75
+ stream = adapter.run_stream(deps=deps, **kwargs)
76
+ try:
77
+ async for event in stream:
78
+ yield event
79
+ finally:
80
+ closer = getattr(stream, "aclose", None)
81
+ if closer is not None:
82
+ await closer()
83
+
84
+
85
+ def _with_deferred(output_type: Any) -> list[Any] | None:
86
+ """``[*output types, DeferredToolRequests]``, or ``None`` if it is already there."""
87
+ if isinstance(output_type, Sequence) and not isinstance(output_type, str | bytes):
88
+ types = list(output_type)
89
+ else:
90
+ types = [output_type]
91
+ if any(t is DeferredToolRequests for t in types):
92
+ return None
93
+ return [*types, DeferredToolRequests]
@@ -0,0 +1,21 @@
1
+ """Chat components built from Reflex built-ins only (API §10, Constitution Art. 2)."""
2
+
3
+ from reflex_agentkit.components.approval import InterruptRenderer, approval_dialog
4
+ from reflex_agentkit.components.chat import agent_chat
5
+ from reflex_agentkit.components.composer import composer
6
+ from reflex_agentkit.components.messages import message_list
7
+ from reflex_agentkit.components.shared_state import shared_state_panel
8
+ from reflex_agentkit.components.status import run_status_bar
9
+ from reflex_agentkit.components.tools import ToolRenderer, tool_call_card
10
+
11
+ __all__ = [
12
+ "InterruptRenderer",
13
+ "ToolRenderer",
14
+ "agent_chat",
15
+ "approval_dialog",
16
+ "composer",
17
+ "message_list",
18
+ "run_status_bar",
19
+ "shared_state_panel",
20
+ "tool_call_card",
21
+ ]
@@ -0,0 +1,123 @@
1
+ """``approval_dialog``: tool approvals and generic interrupts (API §10, REQ-023.5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from typing import Any
7
+
8
+ import reflex as rx
9
+
10
+ from reflex_agentkit.core.models import PendingInterrupt, ToolCall
11
+
12
+ __all__ = ["InterruptRenderer", "approval_dialog"]
13
+
14
+ InterruptRenderer = Callable[[rx.Var[PendingInterrupt], type[Any]], rx.Component]
15
+
16
+
17
+ def _approval(state: type[Any], call: rx.Var[ToolCall]) -> rx.Component:
18
+ """Approve, approve with edited arguments, or reject one pending call."""
19
+ return rx.vstack(
20
+ rx.dialog.title(rx.text("Approve ", call.name, "?")),
21
+ rx.el.pre(call.args_text, style={"white_space": "pre-wrap"}),
22
+ rx.hstack(
23
+ rx.button(
24
+ "Approve",
25
+ on_click=state.approve_tool(call.id),
26
+ aria_label="Approve tool call",
27
+ ),
28
+ width="100%",
29
+ ),
30
+ rx.el.details(
31
+ rx.el.summary("Edit arguments"),
32
+ rx.form(
33
+ rx.vstack(
34
+ rx.text_area(
35
+ name="edited_args",
36
+ default_value=call.args_text,
37
+ aria_label="Edited arguments",
38
+ width="100%",
39
+ rows="6",
40
+ ),
41
+ rx.button("Approve with edits", type="submit", aria_label="Approve with edits"),
42
+ ),
43
+ on_submit=lambda form: state.approve_tool(call.id, form.to(dict)["edited_args"]),
44
+ ),
45
+ ),
46
+ rx.form(
47
+ rx.hstack(
48
+ rx.input(name="reason", placeholder="Reason (optional)", aria_label="Reason"),
49
+ rx.button(
50
+ "Reject", type="submit", color_scheme="red", aria_label="Reject tool call"
51
+ ),
52
+ width="100%",
53
+ ),
54
+ on_submit=lambda form: state.reject_tool(call.id, form.to(dict)["reason"]),
55
+ reset_on_submit=True,
56
+ ),
57
+ spacing="3",
58
+ width="100%",
59
+ )
60
+
61
+
62
+ def _default_interrupt(interrupt: rx.Var[PendingInterrupt], state: type[Any]) -> rx.Component:
63
+ """The interrupt message and a JSON (or text) payload editor (REQ-023.5)."""
64
+ return rx.form(
65
+ rx.vstack(
66
+ rx.text(interrupt.message),
67
+ rx.text_area(
68
+ name="payload",
69
+ placeholder="Response (JSON or text)",
70
+ aria_label="Response",
71
+ width="100%",
72
+ rows="4",
73
+ ),
74
+ rx.button("Send response", type="submit", aria_label="Send response"),
75
+ spacing="2",
76
+ width="100%",
77
+ ),
78
+ on_submit=lambda form: state.respond_to_interrupt(form.to(dict)["payload"]),
79
+ reset_on_submit=True,
80
+ )
81
+
82
+
83
+ def _interrupt(state: type[Any], renderers: Mapping[str, InterruptRenderer]) -> rx.Component:
84
+ interrupt = state.pending_interrupt
85
+ if renderers:
86
+ body = rx.match(
87
+ interrupt.reason,
88
+ *((reason, render(interrupt, state)) for reason, render in renderers.items()),
89
+ _default_interrupt(interrupt, state),
90
+ )
91
+ else:
92
+ body = _default_interrupt(interrupt, state)
93
+ return rx.vstack(
94
+ rx.dialog.title(interrupt.reason),
95
+ rx.text("Question ", interrupt.queue_position, " of ", interrupt.queue_size, size="1"),
96
+ body,
97
+ spacing="3",
98
+ width="100%",
99
+ )
100
+
101
+
102
+ def approval_dialog(
103
+ state: type[Any],
104
+ *,
105
+ interrupt_renderers: Mapping[str, InterruptRenderer] | None = None,
106
+ **props: Any,
107
+ ) -> rx.Component:
108
+ """A dialog open while an approval or a generic interrupt waits; approvals come first."""
109
+ renderers = dict(interrupt_renderers or {})
110
+ has_approval = state.pending_approvals.length() > 0
111
+ open_ = has_approval | (state.pending_interrupt.is_not_none())
112
+ return rx.dialog.root(
113
+ rx.dialog.content(
114
+ rx.cond(
115
+ has_approval,
116
+ _approval(state, state.pending_approvals[0]),
117
+ rx.cond(state.pending_interrupt, _interrupt(state, renderers)),
118
+ ),
119
+ rx.dialog.description("The agent is waiting for your answer.", size="1"),
120
+ **props,
121
+ ),
122
+ open=open_,
123
+ )
@@ -0,0 +1,42 @@
1
+ """``agent_chat``: the full chat UI bound to an ``AgentState`` (API §10, REQ-023.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ import reflex as rx
9
+
10
+ from reflex_agentkit.components.approval import InterruptRenderer, approval_dialog
11
+ from reflex_agentkit.components.composer import composer
12
+ from reflex_agentkit.components.messages import message_list
13
+ from reflex_agentkit.components.shared_state import shared_state_panel
14
+ from reflex_agentkit.components.status import run_status_bar
15
+ from reflex_agentkit.components.tools import ToolRenderer
16
+
17
+ __all__ = ["agent_chat"]
18
+
19
+
20
+ def agent_chat(
21
+ state: type[Any],
22
+ *,
23
+ tool_renderers: Mapping[str, ToolRenderer] | None = None,
24
+ interrupt_renderers: Mapping[str, InterruptRenderer] | None = None,
25
+ show_shared_state: bool = False,
26
+ placeholder: str = "Send a message…",
27
+ **props: Any,
28
+ ) -> rx.Component:
29
+ """Messages, tool cards, the status bar, the composer and the approval dialog."""
30
+ props.setdefault("width", "100%")
31
+ props.setdefault("max_width", "48rem")
32
+ props.setdefault("margin_x", "auto")
33
+ props.setdefault("padding", "1em")
34
+ parts: list[rx.Component] = [message_list(state, tool_renderers=tool_renderers)]
35
+ if show_shared_state:
36
+ parts.append(shared_state_panel(state))
37
+ parts += [
38
+ run_status_bar(state),
39
+ composer(state, placeholder=placeholder),
40
+ approval_dialog(state, interrupt_renderers=interrupt_renderers),
41
+ ]
42
+ return rx.vstack(*parts, spacing="4", **props)