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