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,147 @@
|
|
|
1
|
+
"""``HttpAGUIClient``: AG-UI over HTTP + SSE (API §7.3, §8).
|
|
2
|
+
|
|
3
|
+
The client POSTs the ``RunAgentInput`` as camelCase JSON and decodes the SSE response
|
|
4
|
+
into validated events. Transport failures become ``AgentHTTPError`` or
|
|
5
|
+
``AgentStreamError``; it never yields a synthetic ``RUN_ERROR``. Headers (often API
|
|
6
|
+
keys) are sent upstream only: they are never logged, stored, or put in an exception
|
|
7
|
+
message (REQ-021.3, REQ-021.6, Constitution Art. 3).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from ag_ui.core import BaseEvent, EventType, RunAgentInput
|
|
18
|
+
|
|
19
|
+
from reflex_agentkit.core.clients.base import validate_event
|
|
20
|
+
from reflex_agentkit.core.clients.sse import SSEParser
|
|
21
|
+
from reflex_agentkit.core.errors import AgentHTTPError, AgentStreamError
|
|
22
|
+
|
|
23
|
+
__all__ = ["DEFAULT_TIMEOUT", "HttpAGUIClient"]
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("reflex_agentkit.core.clients.http")
|
|
26
|
+
|
|
27
|
+
DEFAULT_TIMEOUT = httpx.Timeout(10.0, read=60.0)
|
|
28
|
+
BODY_EXCERPT_BYTES = 1024
|
|
29
|
+
_TERMINAL = frozenset({EventType.RUN_FINISHED, EventType.RUN_ERROR})
|
|
30
|
+
|
|
31
|
+
HeadersSource = Mapping[str, str] | Callable[[], Mapping[str, str]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HttpAGUIClient:
|
|
35
|
+
"""Streams a run from an AG-UI HTTP endpoint (REQ-021.2-4, REQ-021.6).
|
|
36
|
+
|
|
37
|
+
Safe to share across sessions: it keeps no per-run state.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
url: The AG-UI endpoint.
|
|
41
|
+
headers: Static headers, or a callable evaluated per run (rotating tokens).
|
|
42
|
+
timeout: An ``httpx.Timeout`` or seconds; ``read`` bounds the gap between bytes.
|
|
43
|
+
http_client: A caller-owned ``httpx.AsyncClient``; ``aclose()`` leaves it open.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
url: str,
|
|
49
|
+
*,
|
|
50
|
+
headers: HeadersSource | None = None,
|
|
51
|
+
timeout: httpx.Timeout | float = DEFAULT_TIMEOUT,
|
|
52
|
+
http_client: httpx.AsyncClient | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._url = url
|
|
55
|
+
self._headers = headers
|
|
56
|
+
self._timeout = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout)
|
|
57
|
+
self._injected = http_client
|
|
58
|
+
self._owned: httpx.AsyncClient | None = None
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
return f"{type(self).__name__}(url={self._url!r})"
|
|
62
|
+
|
|
63
|
+
def _client(self) -> httpx.AsyncClient:
|
|
64
|
+
if self._injected is not None:
|
|
65
|
+
return self._injected
|
|
66
|
+
if self._owned is None or self._owned.is_closed:
|
|
67
|
+
self._owned = httpx.AsyncClient()
|
|
68
|
+
return self._owned
|
|
69
|
+
|
|
70
|
+
def _request_headers(self) -> dict[str, str]:
|
|
71
|
+
extra = self._headers() if callable(self._headers) else (self._headers or {})
|
|
72
|
+
return {**extra, "Content-Type": "application/json", "Accept": "text/event-stream"}
|
|
73
|
+
|
|
74
|
+
async def stream(self, run_input: RunAgentInput) -> AsyncIterator[BaseEvent]:
|
|
75
|
+
"""POST ``run_input``; yield validated events until a terminal one (API §8.2)."""
|
|
76
|
+
body = run_input.model_dump_json(by_alias=True, exclude_none=True)
|
|
77
|
+
try:
|
|
78
|
+
async with self._client().stream(
|
|
79
|
+
"POST",
|
|
80
|
+
self._url,
|
|
81
|
+
content=body,
|
|
82
|
+
headers=self._request_headers(),
|
|
83
|
+
timeout=self._timeout,
|
|
84
|
+
) as response:
|
|
85
|
+
await _check(response)
|
|
86
|
+
parser = SSEParser()
|
|
87
|
+
async for chunk in response.aiter_bytes():
|
|
88
|
+
for data in parser.feed(chunk):
|
|
89
|
+
event = _decode(data)
|
|
90
|
+
if event is None:
|
|
91
|
+
continue
|
|
92
|
+
yield event
|
|
93
|
+
if event.type in _TERMINAL:
|
|
94
|
+
return
|
|
95
|
+
parser.finish()
|
|
96
|
+
except httpx.ReadTimeout:
|
|
97
|
+
raise AgentStreamError(
|
|
98
|
+
"timeout", "read timeout: the agent sent nothing in time"
|
|
99
|
+
) from None
|
|
100
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
|
101
|
+
raise AgentStreamError(
|
|
102
|
+
"connect", f"cannot connect to the agent ({type(exc).__name__})"
|
|
103
|
+
) from None
|
|
104
|
+
except httpx.TransportError as exc:
|
|
105
|
+
raise AgentStreamError(
|
|
106
|
+
"eof", f"connection to the agent lost ({type(exc).__name__})"
|
|
107
|
+
) from None
|
|
108
|
+
raise AgentStreamError("eof", "stream ended unexpectedly")
|
|
109
|
+
|
|
110
|
+
async def aclose(self) -> None:
|
|
111
|
+
"""Close the owned ``httpx.AsyncClient``, if any. Idempotent."""
|
|
112
|
+
owned, self._owned = self._owned, None
|
|
113
|
+
if owned is not None:
|
|
114
|
+
await owned.aclose()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _check(response: httpx.Response) -> None:
|
|
118
|
+
"""Raise for non-2xx status and non-SSE content types (API §8.2)."""
|
|
119
|
+
if not response.is_success:
|
|
120
|
+
excerpt = await _excerpt(response)
|
|
121
|
+
logger.debug(
|
|
122
|
+
"agent endpoint returned HTTP %s; body excerpt: %s", response.status_code, excerpt
|
|
123
|
+
)
|
|
124
|
+
raise AgentHTTPError(response.status_code)
|
|
125
|
+
media_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
126
|
+
if media_type != "text/event-stream":
|
|
127
|
+
raise AgentStreamError(
|
|
128
|
+
"content_type", f"unexpected content type {media_type or '<none>'!r}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _excerpt(response: httpx.Response) -> str:
|
|
133
|
+
collected = b""
|
|
134
|
+
async for chunk in response.aiter_bytes():
|
|
135
|
+
collected += chunk
|
|
136
|
+
if len(collected) >= BODY_EXCERPT_BYTES:
|
|
137
|
+
break
|
|
138
|
+
return collected[:BODY_EXCERPT_BYTES].decode(errors="replace")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _decode(data: str) -> BaseEvent | None:
|
|
142
|
+
try:
|
|
143
|
+
raw = json.loads(data)
|
|
144
|
+
except ValueError:
|
|
145
|
+
logger.warning("skipped an SSE frame that is not valid JSON")
|
|
146
|
+
return None
|
|
147
|
+
return validate_event(raw)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""``InProcessAGUIClient``: run an ``AGUIAgent`` in the current event loop (API §7.3).
|
|
2
|
+
|
|
3
|
+
No HTTP and no serialization: the agent's event objects are validated and passed on
|
|
4
|
+
(REQ-021.5). Cancelling the consumer cancels the agent, which observes
|
|
5
|
+
``CancelledError`` (REQ-007.3). An exception raised by the agent becomes
|
|
6
|
+
``AgentStreamError(reason="agent_raised")`` with the original as ``__cause__``; its
|
|
7
|
+
message names only the exception type, since agent errors may quote secrets.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import AsyncIterator
|
|
13
|
+
|
|
14
|
+
from ag_ui.core import BaseEvent, EventType, RunAgentInput
|
|
15
|
+
|
|
16
|
+
from reflex_agentkit.core.clients.base import AGUIAgent, validate_event
|
|
17
|
+
from reflex_agentkit.core.errors import AgentStreamError, ConfigurationError
|
|
18
|
+
|
|
19
|
+
__all__ = ["InProcessAGUIClient"]
|
|
20
|
+
|
|
21
|
+
_TERMINAL = frozenset({EventType.RUN_FINISHED, EventType.RUN_ERROR})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InProcessAGUIClient:
|
|
25
|
+
"""Wraps an in-process ``AGUIAgent`` as an ``AgentClient`` (REQ-021.5).
|
|
26
|
+
|
|
27
|
+
Safe to share across sessions as long as the agent is: it keeps no per-run state.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, agent: AGUIAgent) -> None:
|
|
31
|
+
if not isinstance(agent, AGUIAgent):
|
|
32
|
+
raise ConfigurationError(
|
|
33
|
+
f"InProcessAGUIClient needs an AGUIAgent (an object with run()), "
|
|
34
|
+
f"got {type(agent).__name__}"
|
|
35
|
+
)
|
|
36
|
+
self._agent = agent
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
return f"{type(self).__name__}(agent={type(self._agent).__name__})"
|
|
40
|
+
|
|
41
|
+
async def stream(self, run_input: RunAgentInput) -> AsyncIterator[BaseEvent]:
|
|
42
|
+
"""Yield the agent's validated events until a terminal one."""
|
|
43
|
+
iterator = self._agent.run(run_input)
|
|
44
|
+
try:
|
|
45
|
+
while True:
|
|
46
|
+
try:
|
|
47
|
+
raw = await anext(iterator)
|
|
48
|
+
except StopAsyncIteration:
|
|
49
|
+
break
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
raise AgentStreamError(
|
|
52
|
+
"agent_raised", f"the agent raised {type(exc).__name__}"
|
|
53
|
+
) from exc
|
|
54
|
+
event = validate_event(raw)
|
|
55
|
+
if event is None:
|
|
56
|
+
continue
|
|
57
|
+
yield event
|
|
58
|
+
if event.type in _TERMINAL:
|
|
59
|
+
return
|
|
60
|
+
finally:
|
|
61
|
+
closer = getattr(iterator, "aclose", None) # plain async iterators lack it
|
|
62
|
+
if closer is not None:
|
|
63
|
+
await closer()
|
|
64
|
+
raise AgentStreamError("eof", "the agent's event stream ended unexpectedly")
|
|
65
|
+
|
|
66
|
+
async def aclose(self) -> None:
|
|
67
|
+
"""Nothing to release; present for the ``AgentClient`` protocol. Idempotent."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Incremental Server-Sent Events parser (API §8.2, REQ-021.2).
|
|
2
|
+
|
|
3
|
+
Only the ``data`` field matters: each dispatched frame's data lines are joined with
|
|
4
|
+
``\\n`` and returned. ``event``, ``id``, ``retry``, unknown fields and comments are
|
|
5
|
+
ignored. CRLF, LF and CR line endings are accepted, including a CRLF split across chunks,
|
|
6
|
+
and bytes are decoded incrementally so a UTF-8 character may span chunks. A frame without
|
|
7
|
+
its terminating blank line at EOF is discarded, as the SSE specification requires (a
|
|
8
|
+
lone CR at EOF counts as a complete line ending).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import codecs
|
|
14
|
+
|
|
15
|
+
__all__ = ["SSEParser"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SSEParser:
|
|
19
|
+
"""Feed byte or str chunks; get back the ``data`` payload of each complete frame."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._reset()
|
|
23
|
+
|
|
24
|
+
def _reset(self) -> None:
|
|
25
|
+
self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
26
|
+
self._buffer = ""
|
|
27
|
+
self._data: list[str] = []
|
|
28
|
+
self._started = False
|
|
29
|
+
|
|
30
|
+
def feed(self, chunk: bytes | str) -> list[str]:
|
|
31
|
+
"""Consume ``chunk``; return the payloads of the frames it completes."""
|
|
32
|
+
text = self._decoder.decode(chunk) if isinstance(chunk, bytes) else chunk
|
|
33
|
+
if not self._started and text:
|
|
34
|
+
self._started = True
|
|
35
|
+
text = text.removeprefix("")
|
|
36
|
+
self._buffer += text
|
|
37
|
+
frames: list[str] = []
|
|
38
|
+
while True:
|
|
39
|
+
line, ending = self._next_line()
|
|
40
|
+
if ending is None:
|
|
41
|
+
return frames
|
|
42
|
+
if line == "":
|
|
43
|
+
if self._data:
|
|
44
|
+
frames.append("\n".join(self._data))
|
|
45
|
+
self._data = []
|
|
46
|
+
continue
|
|
47
|
+
self._field(line)
|
|
48
|
+
|
|
49
|
+
def finish(self) -> list[str]:
|
|
50
|
+
"""End of stream: return a frame a final lone CR completes, drop the rest, reset."""
|
|
51
|
+
frames = self.feed("\n") if self._buffer.endswith("\r") else []
|
|
52
|
+
self._reset()
|
|
53
|
+
return frames
|
|
54
|
+
|
|
55
|
+
def _next_line(self) -> tuple[str, str | None]:
|
|
56
|
+
"""Pop one terminated line from the buffer; ``(…, None)`` if none is complete."""
|
|
57
|
+
buffer = self._buffer
|
|
58
|
+
for index, char in enumerate(buffer):
|
|
59
|
+
if char == "\n":
|
|
60
|
+
self._buffer = buffer[index + 1 :]
|
|
61
|
+
return buffer[:index], "\n"
|
|
62
|
+
if char == "\r":
|
|
63
|
+
if index + 1 == len(buffer):
|
|
64
|
+
return "", None # may be the first half of CRLF; wait for more
|
|
65
|
+
skip = 2 if buffer[index + 1] == "\n" else 1
|
|
66
|
+
self._buffer = buffer[index + skip :]
|
|
67
|
+
return buffer[:index], "\r"
|
|
68
|
+
return "", None
|
|
69
|
+
|
|
70
|
+
def _field(self, line: str) -> None:
|
|
71
|
+
if line.startswith(":"):
|
|
72
|
+
return
|
|
73
|
+
name, _, value = line.partition(":")
|
|
74
|
+
if name == "data":
|
|
75
|
+
self._data.append(value.removeprefix(" "))
|