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,520 @@
|
|
|
1
|
+
"""The run driver and its port to Reflex (TD §3.3 Flows A-E, §5.2).
|
|
2
|
+
|
|
3
|
+
``StateAccess`` is the driver's only way to touch the Reflex state: ``locked()`` holds
|
|
4
|
+
the session's state lock and yields a :class:`StateView` with the stored conversation,
|
|
5
|
+
the pending cancel request and a ``publish`` that writes the conversation back and
|
|
6
|
+
projects it onto the public vars. ``AgentState`` implements it over ``async with self``;
|
|
7
|
+
tests use an in-memory fake, so the driver is tested without Reflex.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import dataclasses
|
|
15
|
+
import enum
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
18
|
+
from contextlib import AbstractAsyncContextManager
|
|
19
|
+
from typing import Protocol
|
|
20
|
+
|
|
21
|
+
from ag_ui.core import BaseEvent, CustomEvent, RunAgentInput, RunFinishedEvent
|
|
22
|
+
|
|
23
|
+
from reflex_agentkit.core._log import RunLogger, get_logger
|
|
24
|
+
from reflex_agentkit.core.clients.base import AgentClient
|
|
25
|
+
from reflex_agentkit.core.commands import (
|
|
26
|
+
apply_cancel,
|
|
27
|
+
apply_client_tool_result,
|
|
28
|
+
apply_failure,
|
|
29
|
+
begin_run,
|
|
30
|
+
request_local_approvals,
|
|
31
|
+
revert_shared_state,
|
|
32
|
+
start_client_tool_round,
|
|
33
|
+
)
|
|
34
|
+
from reflex_agentkit.core.conversation import ConversationState
|
|
35
|
+
from reflex_agentkit.core.errors import AgentHTTPError, AgentStreamError, ErrorCode
|
|
36
|
+
from reflex_agentkit.core.models import (
|
|
37
|
+
CustomEventRecord,
|
|
38
|
+
JsonObject,
|
|
39
|
+
ReducerConfig,
|
|
40
|
+
RunStatus,
|
|
41
|
+
ToolCall,
|
|
42
|
+
ToolCallStatus,
|
|
43
|
+
)
|
|
44
|
+
from reflex_agentkit.core.reducer import reduce
|
|
45
|
+
from reflex_agentkit.core.run_input import RunInputBuilder
|
|
46
|
+
from reflex_agentkit.core.tools import ClientTool
|
|
47
|
+
from reflex_agentkit.state._registry import REGISTRY, RunRegistry
|
|
48
|
+
|
|
49
|
+
__all__ = ["DriverSettings", "RunDriver", "StateAccess", "StateView"]
|
|
50
|
+
|
|
51
|
+
_logger = get_logger("state.driver")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
55
|
+
class StateView:
|
|
56
|
+
"""What the driver sees while it holds the state lock.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
conv: The conversation stored in the session's state.
|
|
60
|
+
cancel_requested: The run ID that ``cancel_run`` asked to stop (``""`` if none).
|
|
61
|
+
client_token: The Reflex client token of the session.
|
|
62
|
+
publish: Store a conversation and project it onto the public vars.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
conv: ConversationState
|
|
66
|
+
cancel_requested: str
|
|
67
|
+
client_token: str
|
|
68
|
+
publish: Callable[[ConversationState], None]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class StateAccess(Protocol):
|
|
72
|
+
"""The driver's port to the session state (TD §5.2)."""
|
|
73
|
+
|
|
74
|
+
def locked(self) -> AbstractAsyncContextManager[StateView]:
|
|
75
|
+
"""Hold the state lock for the duration of the ``async with`` block."""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- the driver --------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
_END = object()
|
|
82
|
+
"""Queue sentinel: the client's stream is exhausted."""
|
|
83
|
+
|
|
84
|
+
MIN_TICK_S = 0.1
|
|
85
|
+
"""A lock section happens at least this often while the upstream is silent (Flow D.3)."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
89
|
+
class _Failure:
|
|
90
|
+
error: BaseException
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _Ownership(enum.Enum):
|
|
94
|
+
"""The outcome of a lock section (Flow A.7, D.3)."""
|
|
95
|
+
|
|
96
|
+
KEPT = "kept"
|
|
97
|
+
LOST = "lost"
|
|
98
|
+
CANCELLED = "cancelled"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
SHUTDOWN_MESSAGE = "the run was interrupted: the server is shutting down"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
105
|
+
class DriverSettings:
|
|
106
|
+
"""The ``AgentState`` configuration the driver needs (API §4.1)."""
|
|
107
|
+
|
|
108
|
+
reducer_config: ReducerConfig
|
|
109
|
+
flush_interval_ms: int = 50
|
|
110
|
+
client_tools: Sequence[ClientTool] = ()
|
|
111
|
+
client_tool_timeout_s: float = 30.0
|
|
112
|
+
max_client_tool_rounds: int = 8
|
|
113
|
+
queue_size: int = 1024
|
|
114
|
+
validate_shared_state: Callable[[JsonObject], bool] | None = None
|
|
115
|
+
"""Checks every shared-state value the agent sends (``shared_state_model``, REQ-016.5)."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
CustomHook = Callable[[list[CustomEventRecord]], Awaitable[None]]
|
|
119
|
+
RunEndHook = Callable[[RunStatus], Awaitable[None]]
|
|
120
|
+
InputHook = Callable[[RunAgentInput], RunAgentInput]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _failure(conv: ConversationState, error: BaseException, log: RunLogger) -> ConversationState:
|
|
124
|
+
"""Map a stream failure onto the conversation (TD Flow E).
|
|
125
|
+
|
|
126
|
+
Only the exception type is logged above DEBUG: agent errors may quote user content
|
|
127
|
+
(REQ-027.2). The traceback is logged at DEBUG.
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(error, AgentHTTPError):
|
|
130
|
+
code, message = ErrorCode.AGENT_HTTP_ERROR, str(error)
|
|
131
|
+
elif isinstance(error, AgentStreamError) and error.reason != "agent_raised":
|
|
132
|
+
code, message = ErrorCode.AGENT_STREAM_ERROR, str(error)
|
|
133
|
+
elif isinstance(error, AgentStreamError):
|
|
134
|
+
code, message = ErrorCode.AGENT_EXCEPTION, str(error)
|
|
135
|
+
else:
|
|
136
|
+
code = ErrorCode.AGENT_EXCEPTION
|
|
137
|
+
message = f"the agent client raised {type(error).__name__}"
|
|
138
|
+
cause = error.__cause__ or error
|
|
139
|
+
log.error("run failed: %s (%s)", code.value, type(cause).__name__)
|
|
140
|
+
log.debug("run failure traceback", exc_info=error)
|
|
141
|
+
return apply_failure(conv, code, message)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class RunDriver:
|
|
145
|
+
"""Streams one turn of a conversation into the session state (TD Flows A, B, D, E).
|
|
146
|
+
|
|
147
|
+
It owns its run: it reduces events into a local conversation, publishes it at most
|
|
148
|
+
once per flush interval under the state lock, and stops silently when the stored
|
|
149
|
+
conversation no longer belongs to its run (REQ-026.2). Nothing escapes :meth:`run`.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
access: The port to the session state.
|
|
153
|
+
client_factory: ``create_client()``; called once per run.
|
|
154
|
+
settings: Batching, client tools and limits.
|
|
155
|
+
prepare_input: ``prepare_run_input()``.
|
|
156
|
+
on_flush: Receives the CUSTOM events since the previous flush, under the lock.
|
|
157
|
+
on_run_end: Receives the final status, once, after the final flush.
|
|
158
|
+
registry: Where the driver task is registered for same-worker cancels.
|
|
159
|
+
client_token: The session's Reflex token (the registry key).
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
access: StateAccess,
|
|
165
|
+
*,
|
|
166
|
+
client_factory: Callable[[], AgentClient],
|
|
167
|
+
settings: DriverSettings,
|
|
168
|
+
prepare_input: InputHook | None = None,
|
|
169
|
+
on_flush: CustomHook | None = None,
|
|
170
|
+
on_run_end: RunEndHook | None = None,
|
|
171
|
+
registry: RunRegistry = REGISTRY,
|
|
172
|
+
client_token: str = "",
|
|
173
|
+
) -> None:
|
|
174
|
+
self._access = access
|
|
175
|
+
self._client_factory = client_factory
|
|
176
|
+
self._settings = settings
|
|
177
|
+
self._prepare_input = prepare_input
|
|
178
|
+
self._on_flush = on_flush
|
|
179
|
+
self._on_run_end = on_run_end
|
|
180
|
+
self._registry = registry
|
|
181
|
+
self._token = client_token
|
|
182
|
+
self._builder = RunInputBuilder(client_tools=tuple(settings.client_tools))
|
|
183
|
+
self._tools = {tool.name: tool for tool in settings.client_tools}
|
|
184
|
+
self._rounds = 0
|
|
185
|
+
self._run_id = ""
|
|
186
|
+
self._conv: ConversationState | None = None
|
|
187
|
+
self._log = RunLogger(_logger, thread_id="", run_id="")
|
|
188
|
+
self._events = 0
|
|
189
|
+
self._flushes = 0
|
|
190
|
+
|
|
191
|
+
async def run(self, conv: ConversationState) -> None:
|
|
192
|
+
"""Drive the run that ``begin_run`` started on ``conv`` until it ends."""
|
|
193
|
+
started = time.monotonic()
|
|
194
|
+
self._enter_run(conv)
|
|
195
|
+
self._conv = conv
|
|
196
|
+
final: ConversationState | None = None
|
|
197
|
+
try:
|
|
198
|
+
final = await self._turn(conv)
|
|
199
|
+
except asyncio.CancelledError:
|
|
200
|
+
# Flow D.2: the registry path (or a worker shutdown). The driver owns its task
|
|
201
|
+
# and nothing awaits it, so the cancellation is absorbed, not re-raised.
|
|
202
|
+
task = asyncio.current_task()
|
|
203
|
+
if task is not None:
|
|
204
|
+
task.uncancel()
|
|
205
|
+
final = await self._after_cancel()
|
|
206
|
+
finally:
|
|
207
|
+
self._registry.unregister(self._token, self._run_id)
|
|
208
|
+
status = "ownership lost" if final is None else final.run_status.value
|
|
209
|
+
self._log.info(
|
|
210
|
+
"run ended: status=%s events=%d flushes=%d client_tool_rounds=%d duration=%.3fs",
|
|
211
|
+
status,
|
|
212
|
+
self._events,
|
|
213
|
+
self._flushes,
|
|
214
|
+
self._rounds,
|
|
215
|
+
time.monotonic() - started,
|
|
216
|
+
)
|
|
217
|
+
if final is not None:
|
|
218
|
+
await self._run_end_hook(final.run_status)
|
|
219
|
+
|
|
220
|
+
async def _turn(self, conv: ConversationState) -> ConversationState | None:
|
|
221
|
+
"""Runs and client-tool rounds until the turn ends (Flow A.8, B, C.4).
|
|
222
|
+
|
|
223
|
+
A conversation that starts with running client-tool calls (approved locally,
|
|
224
|
+
Flow C.4) runs their handlers before the first stream.
|
|
225
|
+
"""
|
|
226
|
+
current: ConversationState | None = conv
|
|
227
|
+
while current is not None and current.run_status is RunStatus.RUNNING:
|
|
228
|
+
if self._awaiting_client_tools(current):
|
|
229
|
+
current = await self._client_round(current)
|
|
230
|
+
if current is None or current.run_status is not RunStatus.RUNNING:
|
|
231
|
+
return current
|
|
232
|
+
current = await self._follow_up(current)
|
|
233
|
+
if current is None or current.run_status is not RunStatus.RUNNING:
|
|
234
|
+
return current
|
|
235
|
+
current = await self._stream(current)
|
|
236
|
+
if (
|
|
237
|
+
current is not None
|
|
238
|
+
and current.run_status is RunStatus.RUNNING
|
|
239
|
+
and not self._awaiting_client_tools(current)
|
|
240
|
+
):
|
|
241
|
+
# RUN_FINISHED listed pending calls that are not runnable client tools.
|
|
242
|
+
self._log.warning("run finished with pending calls that cannot run here")
|
|
243
|
+
return await self._final_flush(current.with_(run_status=RunStatus.IDLE), [])
|
|
244
|
+
return current
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _awaiting_client_tools(conv: ConversationState) -> bool:
|
|
248
|
+
return any(c.is_client_tool and c.status is ToolCallStatus.RUNNING for c in conv.tool_calls)
|
|
249
|
+
|
|
250
|
+
async def _client_round(self, conv: ConversationState) -> ConversationState | None:
|
|
251
|
+
"""Flow B.1-4: run the handlers outside the lock, record results, flush."""
|
|
252
|
+
running = [
|
|
253
|
+
c for c in conv.tool_calls if c.is_client_tool and c.status is ToolCallStatus.RUNNING
|
|
254
|
+
]
|
|
255
|
+
to_approve = [c.id for c in running if self._needs_approval(c)]
|
|
256
|
+
ready = [c for c in running if c.id not in to_approve]
|
|
257
|
+
if ready:
|
|
258
|
+
conv = start_client_tool_round(conv, max_rounds=self._settings.max_client_tool_rounds)
|
|
259
|
+
if conv.run_status is not RunStatus.RUNNING:
|
|
260
|
+
self._log.error("run failed: %s", ErrorCode.CLIENT_TOOL_LOOP_LIMIT.value)
|
|
261
|
+
return await self._final_flush(conv, [])
|
|
262
|
+
self._rounds += 1
|
|
263
|
+
self._conv = conv
|
|
264
|
+
self._log.info("client-tool round %d: %d call(s)", conv.client_tool_rounds, len(ready))
|
|
265
|
+
results = await self._run_handlers(ready)
|
|
266
|
+
if not isinstance(results, list):
|
|
267
|
+
return results # cancelled (flag path) or ownership lost meanwhile
|
|
268
|
+
for call, (ok, text) in zip(ready, results, strict=True):
|
|
269
|
+
conv = apply_client_tool_result(conv, call.id, text, is_error=not ok)
|
|
270
|
+
if to_approve:
|
|
271
|
+
conv = request_local_approvals(conv, to_approve)
|
|
272
|
+
self._log.info("waiting for %d local approval(s)", len(to_approve))
|
|
273
|
+
return await self._final_flush(conv, [])
|
|
274
|
+
|
|
275
|
+
def _needs_approval(self, call: ToolCall) -> bool:
|
|
276
|
+
"""Approval-required and not yet approved (an approved call has a local interrupt)."""
|
|
277
|
+
tool = self._tools.get(call.name)
|
|
278
|
+
return tool is not None and tool.requires_approval and call.interrupt_id is None
|
|
279
|
+
|
|
280
|
+
async def _run_handlers(
|
|
281
|
+
self, calls: list[ToolCall]
|
|
282
|
+
) -> list[tuple[bool, str]] | ConversationState | None:
|
|
283
|
+
"""Run the handlers concurrently; keep checking ownership and cancels (Flow D.3)."""
|
|
284
|
+
gathered = asyncio.gather(*(self._invoke(c) for c in calls))
|
|
285
|
+
tick = max(self._settings.flush_interval_ms / 1000, MIN_TICK_S)
|
|
286
|
+
try:
|
|
287
|
+
while True:
|
|
288
|
+
done, _ = await asyncio.wait({gathered}, timeout=tick)
|
|
289
|
+
if done:
|
|
290
|
+
return gathered.result()
|
|
291
|
+
assert self._conv is not None # noqa: S101 - set by _client_round
|
|
292
|
+
outcome = await self._flush(self._conv, [], publish=False)
|
|
293
|
+
if outcome is not _Ownership.KEPT:
|
|
294
|
+
return self._stopped(outcome)
|
|
295
|
+
finally:
|
|
296
|
+
if not gathered.done():
|
|
297
|
+
gathered.cancel()
|
|
298
|
+
await asyncio.wait({gathered})
|
|
299
|
+
|
|
300
|
+
async def _invoke(self, call: ToolCall) -> tuple[bool, str]:
|
|
301
|
+
tool = self._tools.get(call.name)
|
|
302
|
+
if tool is None:
|
|
303
|
+
return False, f"The client tool {call.name!r} is not registered."
|
|
304
|
+
if call.args is None:
|
|
305
|
+
return False, "The tool arguments are not valid JSON."
|
|
306
|
+
timeout = tool.timeout_s
|
|
307
|
+
if timeout is None:
|
|
308
|
+
timeout = self._settings.client_tool_timeout_s
|
|
309
|
+
try:
|
|
310
|
+
ok, text = await tool.invoke(dict(call.args), timeout)
|
|
311
|
+
except Exception as exc: # invoke() returns failures; this is a last resort
|
|
312
|
+
ok, text = False, f"{type(exc).__name__}: {exc}"
|
|
313
|
+
if not ok:
|
|
314
|
+
# The text may quote arguments or handler data: only the name above DEBUG.
|
|
315
|
+
self._log.warning("client tool %s failed", call.name)
|
|
316
|
+
self._log.debug("client tool %s failed: %s", call.name, text)
|
|
317
|
+
return ok, text
|
|
318
|
+
|
|
319
|
+
async def _follow_up(self, conv: ConversationState) -> ConversationState | None:
|
|
320
|
+
"""Flow B.5: start the follow-up run in this task, with a new ``run_id``."""
|
|
321
|
+
follow_up = begin_run(conv, config=conv.config, resume=False)
|
|
322
|
+
outcome = await self._flush(follow_up, [], publish=True) # checked against the old run
|
|
323
|
+
if outcome is not _Ownership.KEPT:
|
|
324
|
+
return self._stopped(outcome)
|
|
325
|
+
self._enter_run(follow_up)
|
|
326
|
+
self._conv = follow_up
|
|
327
|
+
self._log.info("follow-up run started")
|
|
328
|
+
return follow_up
|
|
329
|
+
|
|
330
|
+
def _enter_run(self, conv: ConversationState) -> None:
|
|
331
|
+
if self._run_id:
|
|
332
|
+
self._registry.unregister(self._token, self._run_id)
|
|
333
|
+
self._run_id = conv.run_id
|
|
334
|
+
self._log = RunLogger(_logger, thread_id=conv.thread_id, run_id=conv.run_id)
|
|
335
|
+
task = asyncio.current_task()
|
|
336
|
+
if task is not None:
|
|
337
|
+
self._registry.register(self._token, conv.run_id, task)
|
|
338
|
+
|
|
339
|
+
async def _stream(self, conv: ConversationState) -> ConversationState | None:
|
|
340
|
+
"""One run: start the pump, consume until the end, close the upstream."""
|
|
341
|
+
try:
|
|
342
|
+
client = self._client_factory()
|
|
343
|
+
run_input = self._builder.build(conv)
|
|
344
|
+
if self._prepare_input is not None:
|
|
345
|
+
run_input = self._prepare_input(run_input)
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
self._log.error("could not start the run: %s", type(exc).__name__)
|
|
348
|
+
self._log.debug("start failure traceback", exc_info=exc)
|
|
349
|
+
message = f"the agent client could not be created ({type(exc).__name__})"
|
|
350
|
+
failed = apply_failure(conv, ErrorCode.AGENT_EXCEPTION, message)
|
|
351
|
+
return await self._final_flush(failed, [])
|
|
352
|
+
queue: asyncio.Queue[object] = asyncio.Queue(maxsize=self._settings.queue_size)
|
|
353
|
+
pump = asyncio.create_task(self._pump(client, run_input, queue))
|
|
354
|
+
try:
|
|
355
|
+
return await self._consume(conv, queue)
|
|
356
|
+
finally:
|
|
357
|
+
pump.cancel()
|
|
358
|
+
await asyncio.wait({pump})
|
|
359
|
+
|
|
360
|
+
async def _pump(
|
|
361
|
+
self, client: AgentClient, run_input: RunAgentInput, queue: asyncio.Queue[object]
|
|
362
|
+
) -> None:
|
|
363
|
+
"""Forward the client's events into the bounded queue (DD-003)."""
|
|
364
|
+
stream = client.stream(run_input)
|
|
365
|
+
try:
|
|
366
|
+
async for event in stream:
|
|
367
|
+
await queue.put(event)
|
|
368
|
+
await queue.put(_END)
|
|
369
|
+
except asyncio.CancelledError:
|
|
370
|
+
raise
|
|
371
|
+
except Exception as exc:
|
|
372
|
+
await queue.put(_Failure(exc))
|
|
373
|
+
finally:
|
|
374
|
+
closer = getattr(stream, "aclose", None)
|
|
375
|
+
if closer is not None:
|
|
376
|
+
with contextlib.suppress(Exception):
|
|
377
|
+
await closer()
|
|
378
|
+
|
|
379
|
+
async def _consume(
|
|
380
|
+
self, conv: ConversationState, queue: asyncio.Queue[object]
|
|
381
|
+
) -> ConversationState | None:
|
|
382
|
+
"""Reduce events; flush on deadline, on terminal events and on silent ticks."""
|
|
383
|
+
interval = self._settings.flush_interval_ms / 1000
|
|
384
|
+
tick = max(interval, MIN_TICK_S)
|
|
385
|
+
dirty = False
|
|
386
|
+
deadline = 0.0
|
|
387
|
+
last_lock = time.monotonic()
|
|
388
|
+
custom: list[CustomEventRecord] = []
|
|
389
|
+
while True:
|
|
390
|
+
wait = (deadline if dirty else last_lock + tick) - time.monotonic()
|
|
391
|
+
try:
|
|
392
|
+
item = await asyncio.wait_for(queue.get(), max(wait, 0.0))
|
|
393
|
+
except TimeoutError:
|
|
394
|
+
outcome = await self._flush(conv, custom, publish=dirty)
|
|
395
|
+
if outcome is not _Ownership.KEPT:
|
|
396
|
+
return self._stopped(outcome)
|
|
397
|
+
dirty, custom, last_lock = False, [], time.monotonic()
|
|
398
|
+
continue
|
|
399
|
+
if item is _END:
|
|
400
|
+
message = "the agent's event stream ended unexpectedly"
|
|
401
|
+
failed = apply_failure(conv, ErrorCode.AGENT_STREAM_ERROR, message)
|
|
402
|
+
self._log.error("run failed: %s (no terminal event)", failed.error_code)
|
|
403
|
+
return await self._final_flush(failed, custom)
|
|
404
|
+
if isinstance(item, _Failure):
|
|
405
|
+
return await self._final_flush(_failure(conv, item.error, self._log), custom)
|
|
406
|
+
assert isinstance(item, BaseEvent) # noqa: S101 - the pump only queues events
|
|
407
|
+
conv = self._apply(conv, item, custom)
|
|
408
|
+
if self._is_terminal(conv, item):
|
|
409
|
+
return await self._final_flush(conv, custom)
|
|
410
|
+
now = time.monotonic()
|
|
411
|
+
if not dirty:
|
|
412
|
+
dirty, deadline = True, now + interval
|
|
413
|
+
if now >= deadline:
|
|
414
|
+
outcome = await self._flush(conv, custom, publish=True)
|
|
415
|
+
if outcome is not _Ownership.KEPT:
|
|
416
|
+
return self._stopped(outcome)
|
|
417
|
+
dirty, custom, last_lock = False, [], time.monotonic()
|
|
418
|
+
|
|
419
|
+
def _apply(
|
|
420
|
+
self, conv: ConversationState, event: BaseEvent, custom: list[CustomEventRecord]
|
|
421
|
+
) -> ConversationState:
|
|
422
|
+
"""Reduce one event; log and drop its diagnostics; collect CUSTOM events."""
|
|
423
|
+
self._events += 1
|
|
424
|
+
self._log.debug("event %s", event.type.value)
|
|
425
|
+
running = conv.run_status is RunStatus.RUNNING
|
|
426
|
+
previous_state = conv.shared_state
|
|
427
|
+
conv = reduce(conv, event)
|
|
428
|
+
validate = self._settings.validate_shared_state
|
|
429
|
+
if (
|
|
430
|
+
validate is not None
|
|
431
|
+
and conv.shared_state is not previous_state
|
|
432
|
+
and not validate(conv.shared_state)
|
|
433
|
+
):
|
|
434
|
+
conv = revert_shared_state(conv, previous_state)
|
|
435
|
+
if isinstance(event, CustomEvent) and running:
|
|
436
|
+
custom.append(CustomEventRecord(name=event.name, value=event.value, run_id=conv.run_id))
|
|
437
|
+
if conv.diagnostics:
|
|
438
|
+
for diagnostic in conv.diagnostics:
|
|
439
|
+
# A message may quote content (a failed patch quotes shared-state values),
|
|
440
|
+
# so above DEBUG only the code and the event type are logged (REQ-027.2).
|
|
441
|
+
if diagnostic.level != "debug":
|
|
442
|
+
self._log.warning(
|
|
443
|
+
"reducer diagnostic %s on %s",
|
|
444
|
+
diagnostic.code.value,
|
|
445
|
+
diagnostic.event_type or "an event",
|
|
446
|
+
)
|
|
447
|
+
self._log.debug("%s (%s)", diagnostic.message, diagnostic.code.value)
|
|
448
|
+
conv = conv.with_(diagnostics=())
|
|
449
|
+
self._conv = conv
|
|
450
|
+
return conv
|
|
451
|
+
|
|
452
|
+
def _is_terminal(self, conv: ConversationState, event: BaseEvent) -> bool:
|
|
453
|
+
if conv.run_status is not RunStatus.RUNNING:
|
|
454
|
+
return True
|
|
455
|
+
return isinstance(event, RunFinishedEvent) and event.run_id == self._run_id
|
|
456
|
+
|
|
457
|
+
async def _final_flush(
|
|
458
|
+
self, conv: ConversationState, custom: list[CustomEventRecord]
|
|
459
|
+
) -> ConversationState | None:
|
|
460
|
+
self._conv = conv
|
|
461
|
+
outcome = await self._flush(conv, custom, publish=True)
|
|
462
|
+
return conv if outcome is _Ownership.KEPT else self._stopped(outcome)
|
|
463
|
+
|
|
464
|
+
def _stopped(self, outcome: _Ownership) -> ConversationState | None:
|
|
465
|
+
"""The final conversation after a lock section stopped the run."""
|
|
466
|
+
return self._conv if outcome is _Ownership.CANCELLED else None
|
|
467
|
+
|
|
468
|
+
async def _after_cancel(self) -> ConversationState | None:
|
|
469
|
+
"""The final lock section after the task was cancelled (Flow D.2, Flow E)."""
|
|
470
|
+
conv = self._conv
|
|
471
|
+
if conv is None: # pragma: no cover - run() sets it before anything can cancel
|
|
472
|
+
return None
|
|
473
|
+
async with self._access.locked() as view:
|
|
474
|
+
if view.conv.run_id != self._run_id:
|
|
475
|
+
self._log.info("cancelled after ownership was lost")
|
|
476
|
+
return None
|
|
477
|
+
if view.cancel_requested == self._run_id:
|
|
478
|
+
self._log.info("run cancelled")
|
|
479
|
+
final = apply_cancel(conv)
|
|
480
|
+
else:
|
|
481
|
+
self._log.warning("run interrupted by a worker shutdown")
|
|
482
|
+
final = apply_failure(conv, ErrorCode.AGENT_STREAM_ERROR, SHUTDOWN_MESSAGE)
|
|
483
|
+
view.publish(final)
|
|
484
|
+
self._flushes += 1
|
|
485
|
+
return final
|
|
486
|
+
|
|
487
|
+
async def _flush(
|
|
488
|
+
self, conv: ConversationState, custom: list[CustomEventRecord], *, publish: bool
|
|
489
|
+
) -> _Ownership:
|
|
490
|
+
"""One lock section: the ownership check, then publish and hooks (Flow A.7)."""
|
|
491
|
+
async with self._access.locked() as view:
|
|
492
|
+
if view.conv.run_id != self._run_id:
|
|
493
|
+
self._log.info("ownership lost: the stored conversation changed")
|
|
494
|
+
return _Ownership.LOST
|
|
495
|
+
if view.cancel_requested == self._run_id:
|
|
496
|
+
# Flow D.3: a cancel from another worker; queued events are discarded.
|
|
497
|
+
self._log.info("run cancelled")
|
|
498
|
+
self._conv = apply_cancel(conv)
|
|
499
|
+
view.publish(self._conv)
|
|
500
|
+
self._flushes += 1
|
|
501
|
+
return _Ownership.CANCELLED
|
|
502
|
+
if publish:
|
|
503
|
+
view.publish(conv)
|
|
504
|
+
self._flushes += 1
|
|
505
|
+
if custom and self._on_flush is not None:
|
|
506
|
+
try:
|
|
507
|
+
await self._on_flush(list(custom))
|
|
508
|
+
except Exception as exc:
|
|
509
|
+
self._log.warning("on_custom_event raised %s", type(exc).__name__)
|
|
510
|
+
self._log.debug("on_custom_event traceback", exc_info=exc)
|
|
511
|
+
return _Ownership.KEPT
|
|
512
|
+
|
|
513
|
+
async def _run_end_hook(self, status: RunStatus) -> None:
|
|
514
|
+
if self._on_run_end is None:
|
|
515
|
+
return
|
|
516
|
+
try:
|
|
517
|
+
await self._on_run_end(status)
|
|
518
|
+
except Exception as exc:
|
|
519
|
+
self._log.warning("on_run_end raised %s", type(exc).__name__)
|
|
520
|
+
self._log.debug("on_run_end traceback", exc_info=exc)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The projection of a conversation onto the public state vars (DM §7.2, DD-008).
|
|
2
|
+
|
|
3
|
+
Pure functions, no Reflex: ``AgentState`` writes the :class:`Projection` onto its public
|
|
4
|
+
vars at every flush. Backend-only data (encrypted reasoning values, interrupt answers,
|
|
5
|
+
remembered interrupt IDs, the config) is never part of it (INV-11, REQ-025).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from reflex_agentkit.core.conversation import ConversationState
|
|
15
|
+
from reflex_agentkit.core.errors import ErrorCode
|
|
16
|
+
from reflex_agentkit.core.models import (
|
|
17
|
+
ChatMessage,
|
|
18
|
+
CustomEventRecord,
|
|
19
|
+
MessageRole,
|
|
20
|
+
OpenInterrupt,
|
|
21
|
+
PendingInterrupt,
|
|
22
|
+
RunStatus,
|
|
23
|
+
ToolCall,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = ["VISIBLE_ROLES", "Projection", "project", "refresh_dirty_args"]
|
|
27
|
+
|
|
28
|
+
VISIBLE_ROLES = frozenset({MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL})
|
|
29
|
+
"""The message roles shown in the browser; system and developer messages are not."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
33
|
+
class Projection:
|
|
34
|
+
"""The values of the public vars of API §4.2."""
|
|
35
|
+
|
|
36
|
+
messages: list[ChatMessage]
|
|
37
|
+
tool_calls: list[ToolCall]
|
|
38
|
+
pending_interrupt: PendingInterrupt | None
|
|
39
|
+
shared_state: dict[str, Any]
|
|
40
|
+
shared_state_stale: bool
|
|
41
|
+
run_status: RunStatus
|
|
42
|
+
current_step: str
|
|
43
|
+
thread_id: str
|
|
44
|
+
run_id: str
|
|
45
|
+
error: str
|
|
46
|
+
error_code: ErrorCode | None
|
|
47
|
+
custom_events: list[CustomEventRecord]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def refresh_dirty_args(conv: ConversationState) -> ConversationState:
|
|
51
|
+
"""Parse the streaming args of the calls in ``dirty_tool_args`` (DM H-3).
|
|
52
|
+
|
|
53
|
+
A call whose accumulated text is a valid JSON object gets it as ``args``; the others
|
|
54
|
+
keep theirs. The set is cleared. Without dirty calls ``conv`` is returned unchanged.
|
|
55
|
+
"""
|
|
56
|
+
if not conv.dirty_tool_args:
|
|
57
|
+
return conv
|
|
58
|
+
calls = list(conv.tool_calls)
|
|
59
|
+
for call_id in conv.dirty_tool_args:
|
|
60
|
+
index = conv._tool_index.get(call_id)
|
|
61
|
+
if index is None:
|
|
62
|
+
continue
|
|
63
|
+
parsed = _parse_object(calls[index].args_text)
|
|
64
|
+
if parsed is not None:
|
|
65
|
+
calls[index] = dataclasses.replace(calls[index], args=parsed)
|
|
66
|
+
return conv.with_(tool_calls=tuple(calls), dirty_tool_args=frozenset())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_object(text: str) -> dict[str, Any] | None:
|
|
70
|
+
try:
|
|
71
|
+
value = json.loads(text)
|
|
72
|
+
except (ValueError, RecursionError):
|
|
73
|
+
return None
|
|
74
|
+
return value if isinstance(value, dict) else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def project(conv: ConversationState) -> Projection:
|
|
78
|
+
"""Compute the public vars from ``conv`` (DM §7.2)."""
|
|
79
|
+
conv = refresh_dirty_args(conv)
|
|
80
|
+
reasoning = {
|
|
81
|
+
r.assistant_message_id: r.content for r in conv.reasoning if r.assistant_message_id
|
|
82
|
+
}
|
|
83
|
+
messages = [
|
|
84
|
+
dataclasses.replace(m, reasoning=reasoning[m.id]) if m.id in reasoning else m
|
|
85
|
+
for m in conv.messages
|
|
86
|
+
if m.role in VISIBLE_ROLES
|
|
87
|
+
]
|
|
88
|
+
return Projection(
|
|
89
|
+
messages=messages,
|
|
90
|
+
tool_calls=list(conv.tool_calls),
|
|
91
|
+
pending_interrupt=_pending_interrupt(conv),
|
|
92
|
+
shared_state=json.loads(json.dumps(conv.shared_state)),
|
|
93
|
+
shared_state_stale=conv.shared_state_stale,
|
|
94
|
+
run_status=conv.run_status,
|
|
95
|
+
current_step=conv.current_step,
|
|
96
|
+
thread_id=conv.thread_id,
|
|
97
|
+
run_id=conv.run_id,
|
|
98
|
+
error=conv.error,
|
|
99
|
+
error_code=conv.error_code,
|
|
100
|
+
custom_events=list(conv.custom_events),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _pending_interrupt(conv: ConversationState) -> PendingInterrupt | None:
|
|
105
|
+
"""The first unanswered generic interrupt, with its place in the queue (REQ-014.3)."""
|
|
106
|
+
generic: list[OpenInterrupt] = [
|
|
107
|
+
i for i in conv.interrupts if i.tool_call_id is None and not i.is_local
|
|
108
|
+
]
|
|
109
|
+
for position, interrupt in enumerate(generic, start=1):
|
|
110
|
+
if interrupt.id not in conv.answers:
|
|
111
|
+
return PendingInterrupt(
|
|
112
|
+
id=interrupt.id,
|
|
113
|
+
reason=interrupt.reason,
|
|
114
|
+
message=interrupt.message,
|
|
115
|
+
payload=interrupt.payload,
|
|
116
|
+
response_schema=interrupt.response_schema,
|
|
117
|
+
queue_position=position,
|
|
118
|
+
queue_size=len(generic),
|
|
119
|
+
)
|
|
120
|
+
return None
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""``RunRegistry``: the process-local map of running drivers (TD §5.2, Flow D, DD-004).
|
|
2
|
+
|
|
3
|
+
``cancel_run`` looks the run up here first. A hit means the driver lives in this worker
|
|
4
|
+
and can be cancelled immediately; a miss means it runs in another worker (or has
|
|
5
|
+
finished), and the driver will see ``_cancel_requested`` at its next flush instead.
|
|
6
|
+
Entries hold only the asyncio task: nothing here is serialized or sent anywhere.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
__all__ = ["REGISTRY", "RunRegistry"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RunRegistry:
|
|
17
|
+
"""``(client token, run id) -> driver task`` for the runs of this process."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._tasks: dict[tuple[str, str], asyncio.Task[object]] = {}
|
|
21
|
+
|
|
22
|
+
def __len__(self) -> int:
|
|
23
|
+
return len(self._tasks)
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return f"{type(self).__name__}(runs={len(self._tasks)})"
|
|
27
|
+
|
|
28
|
+
def register(self, token: str, run_id: str, task: asyncio.Task[object]) -> None:
|
|
29
|
+
"""Record the driver task of ``run_id`` for the session ``token``."""
|
|
30
|
+
self._tasks[(token, run_id)] = task
|
|
31
|
+
|
|
32
|
+
def unregister(self, token: str, run_id: str) -> None:
|
|
33
|
+
"""Forget the run. Idempotent."""
|
|
34
|
+
self._tasks.pop((token, run_id), None)
|
|
35
|
+
|
|
36
|
+
def cancel(self, token: str, run_id: str) -> bool:
|
|
37
|
+
"""Cancel the run's task if it lives here and is still running."""
|
|
38
|
+
task = self._tasks.get((token, run_id))
|
|
39
|
+
if task is None or task.done():
|
|
40
|
+
return False
|
|
41
|
+
task.cancel()
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
REGISTRY = RunRegistry()
|
|
46
|
+
"""The registry shared by every ``AgentState`` of this process."""
|