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.
- 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 +1065 -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.0rc1.dist-info/METADATA +457 -0
- reflex_agentkit-0.1.0rc1.dist-info/RECORD +38 -0
- reflex_agentkit-0.1.0rc1.dist-info/WHEEL +4 -0
- 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)
|