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,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."""