langgraph-stream-parser 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/.github/workflows/ci.yml +1 -1
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/CHANGELOG.md +45 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/PKG-INFO +3 -2
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/pyproject.toml +3 -2
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/__init__.py +20 -1
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/session.py +47 -5
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/agui/__init__.py +54 -4
- langgraph_stream_parser-0.5.0/src/langgraph_stream_parser/tasks/__init__.py +54 -0
- langgraph_stream_parser-0.5.0/src/langgraph_stream_parser/tasks/runner.py +286 -0
- langgraph_stream_parser-0.5.0/src/langgraph_stream_parser/tasks/state.py +49 -0
- langgraph_stream_parser-0.5.0/src/langgraph_stream_parser/tasks/store.py +151 -0
- langgraph_stream_parser-0.5.0/tests/test_agui_matrix.py +261 -0
- langgraph_stream_parser-0.5.0/tests/test_tasks.py +421 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/.github/workflows/release.yml +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/.gitignore +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/LICENSE +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/README.md +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/assets/header.svg +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/docs/adr/0001-adopt-ag-ui-for-the-wire.md +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/examples/agent.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/examples/fastapi_websocket.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/examples/jupyter_example.ipynb +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/spec.md +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/__init__.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/base.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/cli.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/fastapi.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/jupyter.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/print.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/agui/__main__.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/compat.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/demo/__init__.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/demo/agent.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/demo/stub.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/events.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/extractors/__init__.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/extractors/base.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/extractors/builtins.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/extractors/interrupts.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/extractors/messages.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/handlers/__init__.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/handlers/messages.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/handlers/updates.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/host/__init__.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/host/__main__.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/host/config.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/host/loader.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/host/workspace.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/parser.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/resume.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/__init__.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/fixtures/__init__.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/fixtures/mocks.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_agui.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_cli_adapter.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_compat.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_demo.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_demo_stub.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_dual_mode.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_events.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_extractors.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_fastapi_adapter.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_generic_extractor.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_host.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_host_config.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_jupyter.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_lc14_compat.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_parser.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_print_adapter.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_real_model.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_reasoning_display.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_resume.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_session_adapter.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_subagent.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_v2_stream.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_wire_contract.py +0 -0
- {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/uv.lock +0 -0
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-06-14
|
|
4
|
+
|
|
5
|
+
An **async task-delegation engine** — the reusable core behind a "delegate a
|
|
6
|
+
task, it runs in the background, track it on a board" surface. Single-process,
|
|
7
|
+
dependency-free; surfaces provide a concrete store.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`langgraph_stream_parser.tasks`** — `TaskRunner` (an asyncio worker pool that
|
|
11
|
+
drives the shared `SessionAdapter`), a `TaskStore` protocol with a dependency-free
|
|
12
|
+
`InMemoryTaskStore` reference impl, the `Task` record + `TaskState` machine
|
|
13
|
+
(`queued → ongoing → review_needed → done/failed/cancelled`), and
|
|
14
|
+
`set_runner`/`get_runner` so agent tools can reach the runner.
|
|
15
|
+
`enqueue()` returns a `task_id` immediately (non-blocking); workers run each
|
|
16
|
+
task as its own session and transition it by the run's outcome; `cancel`,
|
|
17
|
+
`resume` (HITL), and `retry` round out the controls; orphaned `ongoing` tasks
|
|
18
|
+
are requeued on `start()`.
|
|
19
|
+
- **`Session.outcome`** (+ `Session.interrupt` / `Session.error`) — a typed
|
|
20
|
+
terminal-outcome signal set by the session adapter
|
|
21
|
+
(`complete | interrupted | error | cancelled`). Headless consumers read this
|
|
22
|
+
instead of re-inspecting the event stream. Correctly distinguishes a HITL
|
|
23
|
+
pause from completion (the parser always emits a trailing `CompleteEvent`,
|
|
24
|
+
even after an interrupt).
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- `__version__` was stale at `0.2.1` (pyproject was already ahead); now `0.5.0`.
|
|
28
|
+
|
|
29
|
+
### Notes
|
|
30
|
+
- Additive and dependency-free. The engine depends only on the `TaskStore`
|
|
31
|
+
protocol and the public `SessionAdapter` surface, so a surface can back it
|
|
32
|
+
with any store (in-memory, SQLite, …) and later graduate to a remote Agent
|
|
33
|
+
Protocol server without changing the engine.
|
|
34
|
+
|
|
35
|
+
## [0.4.1] - 2026-06-14
|
|
36
|
+
|
|
37
|
+
AG-UI bridge robustness — an edge-case audit (`tests/test_agui_matrix.py`, run against purpose-built tool-calling / interrupting / erroring / checkpointer-less agents) surfaced three real issues, now fixed:
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- **Graphs compiled without a checkpointer no longer hard-crash.** The AG-UI adapter calls `graph.aget_state()`, which raises `No checkpointer set` — common for plain user graphs. `build_agent()` now auto-attaches an in-memory checkpointer when the graph lacks one (AG-UI needs threaded state for interrupts/resume regardless).
|
|
41
|
+
- **Agent exceptions mid-run now emit a terminal `RUN_ERROR`** instead of silently killing the stream / unhandled 500. The endpoint is now a resilient wrapper around the agent run.
|
|
42
|
+
|
|
43
|
+
### Verified (was previously only claimed)
|
|
44
|
+
- Tool calls map to `TOOL_CALL_START`/`ARGS`/`END` + `TOOL_CALL_RESULT`.
|
|
45
|
+
- Interrupts surface as a `CUSTOM` `on_interrupt` event; resume via `forwardedProps.command.resume` continues the run (HITL round-trip).
|
|
46
|
+
- Multi-turn thread state persists; concurrent requests are isolated (per-request agent clone). Note: AG-UI clients must use **unique message ids** per turn — the adapter dedupes by id.
|
|
47
|
+
|
|
3
48
|
## [0.4.0] - 2026-06-14
|
|
4
49
|
|
|
5
50
|
Adopt **AG-UI** for the wire (see `docs/adr/0001-adopt-ag-ui-for-the-wire.md`).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langgraph-stream-parser
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Universal parser + host layer for LangGraph agents — typed stream events, agent-spec loading, layered config,
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Universal parser + host layer for LangGraph agents — typed stream events, agent-spec loading, layered config, an AG-UI bridge, and an async task-delegation engine
|
|
5
5
|
Project-URL: Homepage, https://github.com/dkedar7/langgraph-stream-parser
|
|
6
6
|
Project-URL: Documentation, https://github.com/dkedar7/langgraph-stream-parser#readme
|
|
7
7
|
Project-URL: Repository, https://github.com/dkedar7/langgraph-stream-parser
|
|
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
21
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
23
|
Requires-Python: >=3.11
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "langgraph-stream-parser"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Universal parser + host layer for LangGraph agents — typed stream events, agent-spec loading, layered config,
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
description = "Universal parser + host layer for LangGraph agents — typed stream events, agent-spec loading, layered config, an AG-UI bridge, and an async task-delegation engine"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
7
7
|
requires-python = ">=3.11"
|
|
@@ -24,6 +24,7 @@ classifiers = [
|
|
|
24
24
|
"Programming Language :: Python :: 3.11",
|
|
25
25
|
"Programming Language :: Python :: 3.12",
|
|
26
26
|
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
27
28
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
29
|
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
29
30
|
]
|
|
@@ -57,6 +57,16 @@ from .extractors.builtins import (
|
|
|
57
57
|
)
|
|
58
58
|
from .resume import create_resume_input, prepare_agent_input
|
|
59
59
|
from .host import load_agent_spec, HostConfig, Workspace
|
|
60
|
+
from .tasks import (
|
|
61
|
+
TaskRunner,
|
|
62
|
+
TaskStore,
|
|
63
|
+
InMemoryTaskStore,
|
|
64
|
+
Task,
|
|
65
|
+
TaskState,
|
|
66
|
+
set_runner,
|
|
67
|
+
get_runner,
|
|
68
|
+
outcome_to_state,
|
|
69
|
+
)
|
|
60
70
|
from .compat import (
|
|
61
71
|
stream_graph_updates,
|
|
62
72
|
astream_graph_updates,
|
|
@@ -64,7 +74,7 @@ from .compat import (
|
|
|
64
74
|
aresume_graph_from_interrupt,
|
|
65
75
|
)
|
|
66
76
|
|
|
67
|
-
__version__ = "0.
|
|
77
|
+
__version__ = "0.5.0"
|
|
68
78
|
|
|
69
79
|
__all__ = [
|
|
70
80
|
# Main parser
|
|
@@ -98,6 +108,15 @@ __all__ = [
|
|
|
98
108
|
"load_agent_spec",
|
|
99
109
|
"HostConfig",
|
|
100
110
|
"Workspace",
|
|
111
|
+
# Task-delegation engine
|
|
112
|
+
"TaskRunner",
|
|
113
|
+
"TaskStore",
|
|
114
|
+
"InMemoryTaskStore",
|
|
115
|
+
"Task",
|
|
116
|
+
"TaskState",
|
|
117
|
+
"set_runner",
|
|
118
|
+
"get_runner",
|
|
119
|
+
"outcome_to_state",
|
|
101
120
|
# Serialization
|
|
102
121
|
"event_to_dict",
|
|
103
122
|
# Legacy/compat functions
|
|
@@ -23,7 +23,7 @@ import uuid
|
|
|
23
23
|
from datetime import datetime
|
|
24
24
|
from typing import Any, AsyncIterator
|
|
25
25
|
|
|
26
|
-
from ..events import CompleteEvent, ErrorEvent, event_to_dict
|
|
26
|
+
from ..events import CompleteEvent, ErrorEvent, InterruptEvent, event_to_dict
|
|
27
27
|
from ..parser import StreamParser
|
|
28
28
|
from ..resume import create_resume_input, prepare_agent_input
|
|
29
29
|
|
|
@@ -38,6 +38,19 @@ class Session:
|
|
|
38
38
|
self.current_task: asyncio.Task | None = None
|
|
39
39
|
self.sse_connected: bool = False
|
|
40
40
|
self.created_at: datetime = datetime.now()
|
|
41
|
+
# Typed terminal outcome of the most recent turn, set by ``_produce``.
|
|
42
|
+
# One of ``"complete" | "interrupted" | "error" | "cancelled"``, or
|
|
43
|
+
# ``None`` before the first turn finishes. Headless consumers (e.g. a
|
|
44
|
+
# task runner) read this instead of re-inspecting the event stream to
|
|
45
|
+
# tell whether a turn finished, paused on a HITL interrupt, or failed.
|
|
46
|
+
self.outcome: str | None = None
|
|
47
|
+
# When ``outcome == "interrupted"``, the serialized InterruptEvent
|
|
48
|
+
# (``{"type": "interrupt", "action_requests": [...], ...}``) that paused
|
|
49
|
+
# the turn — everything a consumer needs to render a review gate and
|
|
50
|
+
# build resume decisions. ``None`` otherwise.
|
|
51
|
+
self.interrupt: dict[str, Any] | None = None
|
|
52
|
+
# When ``outcome in {"error"}``, a human-readable error string.
|
|
53
|
+
self.error: str | None = None
|
|
41
54
|
|
|
42
55
|
def cancel_current(self) -> bool:
|
|
43
56
|
"""Cancel the in-flight turn if any. Returns True if one was cancelled."""
|
|
@@ -192,8 +205,18 @@ class SessionAdapter:
|
|
|
192
205
|
return session
|
|
193
206
|
|
|
194
207
|
async def _produce(self, session: Session, input_data: Any) -> None:
|
|
195
|
-
"""Run one turn, pushing serialized events onto the session queue.
|
|
208
|
+
"""Run one turn, pushing serialized events onto the session queue.
|
|
209
|
+
|
|
210
|
+
Also records the turn's terminal outcome on the session
|
|
211
|
+
(``session.outcome`` + ``session.interrupt`` / ``session.error``) so
|
|
212
|
+
headless consumers don't have to re-inspect the event stream.
|
|
213
|
+
"""
|
|
196
214
|
parser = StreamParser(stream_mode=self._stream_mode, **self._parser_kwargs)
|
|
215
|
+
# Fresh turn → clear any prior outcome.
|
|
216
|
+
session.outcome = None
|
|
217
|
+
session.interrupt = None
|
|
218
|
+
session.error = None
|
|
219
|
+
pending_interrupt: dict[str, Any] | None = None
|
|
197
220
|
try:
|
|
198
221
|
stream = self._graph.astream(
|
|
199
222
|
input_data,
|
|
@@ -201,14 +224,33 @@ class SessionAdapter:
|
|
|
201
224
|
stream_mode=self._stream_mode,
|
|
202
225
|
)
|
|
203
226
|
async for event in parser.aparse(stream):
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if isinstance(event,
|
|
227
|
+
data = event_to_dict(event, max_result_len=self._max_result_len)
|
|
228
|
+
session.push(data)
|
|
229
|
+
if isinstance(event, InterruptEvent):
|
|
230
|
+
# The graph paused for HITL. The parser still emits a
|
|
231
|
+
# trailing CompleteEvent when the stream ends, so remember
|
|
232
|
+
# the interrupt and reinterpret that Complete below.
|
|
233
|
+
pending_interrupt = data
|
|
234
|
+
elif isinstance(event, ErrorEvent):
|
|
235
|
+
session.outcome = "error"
|
|
236
|
+
session.error = event.error
|
|
237
|
+
return
|
|
238
|
+
elif isinstance(event, CompleteEvent):
|
|
239
|
+
# A Complete that follows an interrupt means "paused",
|
|
240
|
+
# not "finished" — distinguish the two for consumers.
|
|
241
|
+
if pending_interrupt is not None:
|
|
242
|
+
session.outcome = "interrupted"
|
|
243
|
+
session.interrupt = pending_interrupt
|
|
244
|
+
else:
|
|
245
|
+
session.outcome = "complete"
|
|
207
246
|
return
|
|
208
247
|
except asyncio.CancelledError:
|
|
248
|
+
session.outcome = "cancelled"
|
|
209
249
|
session.push({"type": "cancelled"})
|
|
210
250
|
raise
|
|
211
251
|
except Exception as exc: # noqa: BLE001 — surfaced to the client, not swallowed
|
|
252
|
+
session.outcome = "error"
|
|
253
|
+
session.error = f"{type(exc).__name__}: {exc}"
|
|
212
254
|
session.push({"type": "error", "error": f"{type(exc).__name__}: {exc}"})
|
|
213
255
|
|
|
214
256
|
# ── Consuming (SSE) ──────────────────────────────────────────────
|
|
@@ -24,8 +24,9 @@ Quick start::
|
|
|
24
24
|
from langgraph_stream_parser.agui import build_app
|
|
25
25
|
app = build_app(my_compiled_graph) # an ASGI app; run with uvicorn
|
|
26
26
|
"""
|
|
27
|
-
from __future__ import annotations
|
|
28
|
-
|
|
27
|
+
# NB: intentionally NOT `from __future__ import annotations`. The resilient
|
|
28
|
+
# endpoint below needs real (non-string) annotations so FastAPI can resolve
|
|
29
|
+
# RunAgentInput as the request body; PEP 604 unions work natively on >=3.11.
|
|
29
30
|
from typing import Any
|
|
30
31
|
|
|
31
32
|
__all__ = ["build_agent", "add_agui_endpoint", "build_app", "serve", "DEFAULT_AGENT_NAME"]
|
|
@@ -71,6 +72,17 @@ def build_agent(
|
|
|
71
72
|
from ag_ui_langgraph import LangGraphAgent
|
|
72
73
|
except ImportError as e: # pragma: no cover - exercised only without the extra
|
|
73
74
|
raise RuntimeError(_IMPORT_HINT) from e
|
|
75
|
+
# AG-UI requires threaded state — the adapter calls graph.aget_state() and
|
|
76
|
+
# supports interrupts/resume, both of which need a checkpointer. Many user
|
|
77
|
+
# graphs are compiled without one (and would otherwise hard-crash with
|
|
78
|
+
# "No checkpointer set"), so attach an in-memory default when absent.
|
|
79
|
+
if getattr(graph, "checkpointer", None) is None:
|
|
80
|
+
try:
|
|
81
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
82
|
+
|
|
83
|
+
graph.checkpointer = InMemorySaver()
|
|
84
|
+
except Exception: # pragma: no cover - best-effort; LangGraphAgent will surface real issues
|
|
85
|
+
pass
|
|
74
86
|
return LangGraphAgent(name=name, graph=graph, description=description, config=config)
|
|
75
87
|
|
|
76
88
|
|
|
@@ -87,15 +99,53 @@ def add_agui_endpoint(
|
|
|
87
99
|
|
|
88
100
|
``graph`` may be a compiled graph or an already-built ``LangGraphAgent``.
|
|
89
101
|
Returns the same ``app`` for chaining.
|
|
102
|
+
|
|
103
|
+
The endpoint is *resilient*: if the agent raises mid-run, a terminal
|
|
104
|
+
``RUN_ERROR`` event is emitted and the stream closes cleanly, rather than
|
|
105
|
+
crashing the connection with an unhandled 500 (the bare upstream adapter
|
|
106
|
+
lets node exceptions propagate). Each request runs on its own cloned agent.
|
|
90
107
|
"""
|
|
91
108
|
try:
|
|
92
|
-
from
|
|
109
|
+
from ag_ui.core import EventType, RunAgentInput, RunErrorEvent
|
|
110
|
+
from ag_ui.encoder import EventEncoder
|
|
111
|
+
from fastapi import Request
|
|
112
|
+
from fastapi.responses import StreamingResponse
|
|
93
113
|
except ImportError as e: # pragma: no cover
|
|
94
114
|
raise RuntimeError(_IMPORT_HINT) from e
|
|
115
|
+
|
|
95
116
|
agent = graph if _is_langgraph_agent(graph) else build_agent(
|
|
96
117
|
graph, name=name, description=description, config=config
|
|
97
118
|
)
|
|
98
|
-
|
|
119
|
+
|
|
120
|
+
@app.post(path)
|
|
121
|
+
async def _run(input_data: RunAgentInput, request: Request):
|
|
122
|
+
accept = request.headers.get("accept")
|
|
123
|
+
try:
|
|
124
|
+
encoder = EventEncoder(accept=accept)
|
|
125
|
+
except TypeError: # pragma: no cover - older SDKs without the accept kwarg
|
|
126
|
+
encoder = EventEncoder()
|
|
127
|
+
media_type = getattr(encoder, "get_content_type", lambda: "text/event-stream")()
|
|
128
|
+
run_agent = agent.clone()
|
|
129
|
+
|
|
130
|
+
async def gen():
|
|
131
|
+
try:
|
|
132
|
+
async for ev in run_agent.run(input_data):
|
|
133
|
+
# run() yields SSE-encoded strings; encode objects defensively.
|
|
134
|
+
yield ev if isinstance(ev, (str, bytes)) else encoder.encode(ev)
|
|
135
|
+
except Exception as exc: # noqa: BLE001 - surfaced to the client as RUN_ERROR
|
|
136
|
+
yield encoder.encode(
|
|
137
|
+
RunErrorEvent(
|
|
138
|
+
type=EventType.RUN_ERROR,
|
|
139
|
+
message=f"{type(exc).__name__}: {exc}",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return StreamingResponse(gen(), media_type=media_type)
|
|
144
|
+
|
|
145
|
+
@app.get(path)
|
|
146
|
+
async def _health():
|
|
147
|
+
return {"status": "ok", "agent": {"name": getattr(agent, "name", name)}}
|
|
148
|
+
|
|
99
149
|
return app
|
|
100
150
|
|
|
101
151
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Async task-delegation engine for LangGraph agents.
|
|
2
|
+
|
|
3
|
+
A small, single-process control plane that lets a host accept tasks, run them
|
|
4
|
+
as background agent sessions, and track them through a four-column board
|
|
5
|
+
(queued → ongoing → review_needed → done/failed/cancelled).
|
|
6
|
+
|
|
7
|
+
- :class:`TaskRunner` — the worker pool that drives a ``SessionAdapter``.
|
|
8
|
+
- :class:`TaskStore` — the persistence protocol (surfaces provide a concrete
|
|
9
|
+
store; :class:`InMemoryTaskStore` is a dependency-free reference impl).
|
|
10
|
+
- :data:`TASK_TOOLS` — agent tools so an agent can delegate to copies of
|
|
11
|
+
itself (added in a later release).
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
from langgraph_stream_parser import SessionAdapter
|
|
15
|
+
from langgraph_stream_parser.tasks import TaskRunner, InMemoryTaskStore
|
|
16
|
+
|
|
17
|
+
runner = TaskRunner(adapter, InMemoryTaskStore(), concurrency=3)
|
|
18
|
+
await runner.start()
|
|
19
|
+
task_id = await runner.enqueue(title="research", prompt="...")
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from .runner import TaskRunner, get_runner, set_runner
|
|
24
|
+
from .state import (
|
|
25
|
+
CANCELLED,
|
|
26
|
+
DONE,
|
|
27
|
+
FAILED,
|
|
28
|
+
ONGOING,
|
|
29
|
+
QUEUED,
|
|
30
|
+
REVIEW_NEEDED,
|
|
31
|
+
TERMINAL_STATES,
|
|
32
|
+
TaskState,
|
|
33
|
+
outcome_to_state,
|
|
34
|
+
)
|
|
35
|
+
from .store import InMemoryTaskStore, Task, TaskStore, now_iso
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"TaskRunner",
|
|
39
|
+
"set_runner",
|
|
40
|
+
"get_runner",
|
|
41
|
+
"TaskStore",
|
|
42
|
+
"InMemoryTaskStore",
|
|
43
|
+
"Task",
|
|
44
|
+
"now_iso",
|
|
45
|
+
"TaskState",
|
|
46
|
+
"outcome_to_state",
|
|
47
|
+
"TERMINAL_STATES",
|
|
48
|
+
"QUEUED",
|
|
49
|
+
"ONGOING",
|
|
50
|
+
"REVIEW_NEEDED",
|
|
51
|
+
"DONE",
|
|
52
|
+
"FAILED",
|
|
53
|
+
"CANCELLED",
|
|
54
|
+
]
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""TaskRunner: a single-process async worker pool for delegated agent tasks.
|
|
2
|
+
|
|
3
|
+
Generalizes the cron-scheduler pattern (an asyncio task driving the shared
|
|
4
|
+
``SessionAdapter``) into a durable, board-backed task queue:
|
|
5
|
+
|
|
6
|
+
- ``enqueue`` writes a ``queued`` row and returns a ``task_id`` immediately —
|
|
7
|
+
the caller (an HTTP handler or an agent tool) never blocks on the run.
|
|
8
|
+
- ``concurrency`` worker coroutines each claim the oldest ``queued`` task
|
|
9
|
+
(atomically, via the store), run it as its own ``SessionAdapter`` session,
|
|
10
|
+
and transition it to ``done`` / ``failed`` / ``review_needed`` based on the
|
|
11
|
+
session's typed ``outcome``.
|
|
12
|
+
- on ``start`` any ``ongoing`` rows left by a crash are requeued.
|
|
13
|
+
|
|
14
|
+
The runner depends only on the :class:`TaskStore` protocol and the public
|
|
15
|
+
``SessionAdapter`` surface, so a surface can back it with any store (in-memory,
|
|
16
|
+
SQLite, …) without touching this code.
|
|
17
|
+
|
|
18
|
+
Single-process by design: the worker count *is* the concurrency cap, and the
|
|
19
|
+
atomic claim is only atomic within one process — run one server worker.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
import uuid
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
from .state import (
|
|
29
|
+
CANCELLED,
|
|
30
|
+
DONE,
|
|
31
|
+
FAILED,
|
|
32
|
+
ONGOING,
|
|
33
|
+
QUEUED,
|
|
34
|
+
REVIEW_NEEDED,
|
|
35
|
+
TERMINAL_STATES,
|
|
36
|
+
outcome_to_state,
|
|
37
|
+
)
|
|
38
|
+
from .store import Task, TaskStore, now_iso
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TaskRunner:
|
|
44
|
+
"""Runs delegated tasks on the app's asyncio loop. See module docstring."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
adapter: Any,
|
|
49
|
+
store: TaskStore,
|
|
50
|
+
*,
|
|
51
|
+
concurrency: int = 3,
|
|
52
|
+
thread_prefix: str = "task-",
|
|
53
|
+
context_label: str = "Async task",
|
|
54
|
+
poll_interval: float = 2.0,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._adapter = adapter
|
|
57
|
+
self._store = store
|
|
58
|
+
self._concurrency = max(1, concurrency)
|
|
59
|
+
self._thread_prefix = thread_prefix
|
|
60
|
+
self._context_label = context_label
|
|
61
|
+
self._poll_interval = poll_interval
|
|
62
|
+
self._workers: list[asyncio.Task] = []
|
|
63
|
+
self._side_tasks: set[asyncio.Task] = set() # resume runs, etc.
|
|
64
|
+
self._wake = asyncio.Event()
|
|
65
|
+
self._started = False
|
|
66
|
+
|
|
67
|
+
# ── lifecycle ────────────────────────────────────────────────────
|
|
68
|
+
async def start(self) -> None:
|
|
69
|
+
"""Set up the store, recover orphans, and spawn worker coroutines."""
|
|
70
|
+
if self._started:
|
|
71
|
+
return
|
|
72
|
+
await self._store.setup()
|
|
73
|
+
recovered = await self._store.requeue_orphans()
|
|
74
|
+
if recovered:
|
|
75
|
+
logger.info("TaskRunner requeued %d orphaned task(s) on startup", recovered)
|
|
76
|
+
self._started = True
|
|
77
|
+
self._workers = [
|
|
78
|
+
asyncio.create_task(self._worker(i)) for i in range(self._concurrency)
|
|
79
|
+
]
|
|
80
|
+
# Kick the workers in case there's already queued work.
|
|
81
|
+
self._wake.set()
|
|
82
|
+
|
|
83
|
+
async def shutdown(self) -> None:
|
|
84
|
+
tasks = [*self._workers, *self._side_tasks]
|
|
85
|
+
for t in tasks:
|
|
86
|
+
t.cancel()
|
|
87
|
+
# Await the cancellations so no tasks linger past shutdown (otherwise
|
|
88
|
+
# the event loop can't close cleanly on the way down).
|
|
89
|
+
if tasks:
|
|
90
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
91
|
+
self._workers.clear()
|
|
92
|
+
self._side_tasks.clear()
|
|
93
|
+
self._started = False
|
|
94
|
+
|
|
95
|
+
# ── public API ───────────────────────────────────────────────────
|
|
96
|
+
async def enqueue(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
title: str,
|
|
100
|
+
prompt: str,
|
|
101
|
+
agent_spec: Optional[str] = None,
|
|
102
|
+
parent_id: Optional[str] = None,
|
|
103
|
+
) -> str:
|
|
104
|
+
"""Create a queued task and return its id immediately (non-blocking)."""
|
|
105
|
+
if not prompt or not prompt.strip():
|
|
106
|
+
raise ValueError("Task prompt is required.")
|
|
107
|
+
task_id = uuid.uuid4().hex
|
|
108
|
+
task: Task = {
|
|
109
|
+
"task_id": task_id,
|
|
110
|
+
"parent_id": parent_id,
|
|
111
|
+
"title": (title or prompt).strip()[:200],
|
|
112
|
+
"prompt": prompt.strip(),
|
|
113
|
+
"agent_spec": agent_spec,
|
|
114
|
+
"state": QUEUED,
|
|
115
|
+
"thread_id": f"{self._thread_prefix}{task_id}",
|
|
116
|
+
"created_at": now_iso(),
|
|
117
|
+
"started_at": None,
|
|
118
|
+
"finished_at": None,
|
|
119
|
+
"result": None,
|
|
120
|
+
"artifacts": None,
|
|
121
|
+
"error": None,
|
|
122
|
+
"interrupt": None,
|
|
123
|
+
}
|
|
124
|
+
await self._store.create(task)
|
|
125
|
+
self._wake.set()
|
|
126
|
+
return task_id
|
|
127
|
+
|
|
128
|
+
async def cancel(self, task_id: str) -> bool:
|
|
129
|
+
"""Cancel a task. Stops the in-flight run if it's ``ongoing``."""
|
|
130
|
+
task = await self._store.get(task_id)
|
|
131
|
+
if task is None or task.get("state") in TERMINAL_STATES:
|
|
132
|
+
return False
|
|
133
|
+
# Mark cancelled first, then interrupt the in-flight run. The worker
|
|
134
|
+
# awaiting that run sees the run task's CancelledError (its own
|
|
135
|
+
# ``cancelling()`` is 0) and leaves the already-set state alone.
|
|
136
|
+
await self._store.update(
|
|
137
|
+
task_id, state=CANCELLED, finished_at=now_iso()
|
|
138
|
+
)
|
|
139
|
+
if task.get("state") == ONGOING:
|
|
140
|
+
self._adapter.cancel(task["thread_id"])
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
async def resume(self, task_id: str, decisions: list[dict[str, Any]]) -> bool:
|
|
144
|
+
"""Resume a ``review_needed`` task with HITL decisions (non-blocking).
|
|
145
|
+
|
|
146
|
+
Flips the task back to ``ongoing`` and runs the resume in the
|
|
147
|
+
background so the caller (an approve/reject HTTP handler) returns at
|
|
148
|
+
once.
|
|
149
|
+
"""
|
|
150
|
+
task = await self._store.get(task_id)
|
|
151
|
+
if task is None or task.get("state") != REVIEW_NEEDED:
|
|
152
|
+
return False
|
|
153
|
+
await self._store.update(
|
|
154
|
+
task_id, state=ONGOING, interrupt=None, started_at=now_iso()
|
|
155
|
+
)
|
|
156
|
+
t = asyncio.create_task(self._resume_run(task, decisions))
|
|
157
|
+
self._side_tasks.add(t)
|
|
158
|
+
t.add_done_callback(self._side_tasks.discard)
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
async def retry(self, task_id: str) -> bool:
|
|
162
|
+
"""Re-queue a ``failed`` / ``cancelled`` task (same thread → resumes
|
|
163
|
+
from its last checkpoint where a durable saver is configured)."""
|
|
164
|
+
task = await self._store.get(task_id)
|
|
165
|
+
if task is None or task.get("state") not in {FAILED, CANCELLED}:
|
|
166
|
+
return False
|
|
167
|
+
await self._store.update(
|
|
168
|
+
task_id,
|
|
169
|
+
state=QUEUED,
|
|
170
|
+
error=None,
|
|
171
|
+
finished_at=None,
|
|
172
|
+
started_at=None,
|
|
173
|
+
)
|
|
174
|
+
self._wake.set()
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
# ── internals ────────────────────────────────────────────────────
|
|
178
|
+
async def _worker(self, idx: int) -> None:
|
|
179
|
+
while True:
|
|
180
|
+
try:
|
|
181
|
+
task = await self._store.claim_next()
|
|
182
|
+
except Exception: # pragma: no cover - defensive
|
|
183
|
+
logger.exception("worker %d: claim_next failed", idx)
|
|
184
|
+
task = None
|
|
185
|
+
if task is None:
|
|
186
|
+
# Nothing to do: sleep until woken or the poll timeout fires
|
|
187
|
+
# (the timeout self-heals any missed wake signal).
|
|
188
|
+
try:
|
|
189
|
+
await asyncio.wait_for(self._wake.wait(), timeout=self._poll_interval)
|
|
190
|
+
except asyncio.TimeoutError:
|
|
191
|
+
pass
|
|
192
|
+
self._wake.clear()
|
|
193
|
+
continue
|
|
194
|
+
await self._run_one(task)
|
|
195
|
+
|
|
196
|
+
async def _run_one(self, task: Task) -> None:
|
|
197
|
+
task_id = task["task_id"]
|
|
198
|
+
session = self._adapter.submit_message(
|
|
199
|
+
task["thread_id"],
|
|
200
|
+
task["prompt"],
|
|
201
|
+
context_parts=[f"[{self._context_label}: {task.get('title', task_id)}]"],
|
|
202
|
+
)
|
|
203
|
+
await self._await_run(task_id, session)
|
|
204
|
+
|
|
205
|
+
async def _resume_run(
|
|
206
|
+
self, task: Task, decisions: list[dict[str, Any]]
|
|
207
|
+
) -> None:
|
|
208
|
+
task_id = task["task_id"]
|
|
209
|
+
session = self._adapter.submit_decisions(task["thread_id"], decisions)
|
|
210
|
+
await self._await_run(task_id, session)
|
|
211
|
+
|
|
212
|
+
async def _await_run(self, task_id: str, session: Any) -> None:
|
|
213
|
+
"""Await a session's in-flight turn and record the outcome."""
|
|
214
|
+
try:
|
|
215
|
+
run_task = getattr(session, "current_task", None)
|
|
216
|
+
if run_task is not None:
|
|
217
|
+
await run_task
|
|
218
|
+
except asyncio.CancelledError:
|
|
219
|
+
cur = asyncio.current_task()
|
|
220
|
+
if cur is not None and cur.cancelling() > 0:
|
|
221
|
+
# This worker is itself being cancelled (shutdown) → propagate
|
|
222
|
+
# so it can exit; leave the task ``ongoing`` for restart recovery.
|
|
223
|
+
raise
|
|
224
|
+
# Otherwise the *run* task was cancelled via ``cancel()`` — its
|
|
225
|
+
# state is already set; just drain and stop.
|
|
226
|
+
self._drain(session)
|
|
227
|
+
return
|
|
228
|
+
except Exception: # pragma: no cover - _produce captures its own errors
|
|
229
|
+
logger.exception("task %s run raised", task_id)
|
|
230
|
+
|
|
231
|
+
events = self._drain(session)
|
|
232
|
+
await self._record_outcome(task_id, session, events)
|
|
233
|
+
|
|
234
|
+
async def _record_outcome(
|
|
235
|
+
self, task_id: str, session: Any, events: list[dict[str, Any]]
|
|
236
|
+
) -> None:
|
|
237
|
+
outcome = getattr(session, "outcome", None)
|
|
238
|
+
state = outcome_to_state(outcome)
|
|
239
|
+
fields: dict[str, Any] = {"state": state}
|
|
240
|
+
if state in TERMINAL_STATES:
|
|
241
|
+
fields["finished_at"] = now_iso()
|
|
242
|
+
if state == DONE:
|
|
243
|
+
fields["result"] = self._result_text(events)
|
|
244
|
+
elif state == REVIEW_NEEDED:
|
|
245
|
+
fields["interrupt"] = getattr(session, "interrupt", None)
|
|
246
|
+
elif state == FAILED:
|
|
247
|
+
fields["error"] = getattr(session, "error", None) or "Task run failed."
|
|
248
|
+
await self._store.update(task_id, **fields)
|
|
249
|
+
|
|
250
|
+
@staticmethod
|
|
251
|
+
def _drain(session: Any) -> list[dict[str, Any]]:
|
|
252
|
+
"""Drain a headless session's event queue (no SSE consumer)."""
|
|
253
|
+
events: list[dict[str, Any]] = []
|
|
254
|
+
q = getattr(session, "event_queue", None)
|
|
255
|
+
if q is None:
|
|
256
|
+
return events
|
|
257
|
+
while not q.empty():
|
|
258
|
+
try:
|
|
259
|
+
events.append(q.get_nowait())
|
|
260
|
+
except asyncio.QueueEmpty: # pragma: no cover
|
|
261
|
+
break
|
|
262
|
+
return events
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _result_text(events: list[dict[str, Any]]) -> Optional[str]:
|
|
266
|
+
"""Reconstruct the assistant's final text from streamed content events."""
|
|
267
|
+
parts = [
|
|
268
|
+
e.get("content", "")
|
|
269
|
+
for e in events
|
|
270
|
+
if e.get("type") == "content" and e.get("role", "assistant") == "assistant"
|
|
271
|
+
]
|
|
272
|
+
text = "".join(parts).strip()
|
|
273
|
+
return text or None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ── process-global singleton (so agent tools can reach the runner) ──
|
|
277
|
+
_runner: Optional[TaskRunner] = None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def set_runner(runner: Optional[TaskRunner]) -> None:
|
|
281
|
+
global _runner
|
|
282
|
+
_runner = runner
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def get_runner() -> Optional[TaskRunner]:
|
|
286
|
+
return _runner
|