langgraph-stream-parser 0.4.1__tar.gz → 0.6.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.1 → langgraph_stream_parser-0.6.0}/.github/workflows/ci.yml +1 -1
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/CHANGELOG.md +59 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/PKG-INFO +3 -2
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/pyproject.toml +3 -2
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/__init__.py +24 -1
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/session.py +47 -5
- langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/__init__.py +57 -0
- langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/runner.py +342 -0
- langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/state.py +49 -0
- langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/store.py +167 -0
- langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/tools.py +121 -0
- langgraph_stream_parser-0.6.0/tests/test_tasks.py +529 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/.github/workflows/release.yml +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/.gitignore +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/LICENSE +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/README.md +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/assets/header.svg +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/docs/adr/0001-adopt-ag-ui-for-the-wire.md +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/examples/agent.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/examples/fastapi_websocket.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/examples/jupyter_example.ipynb +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/spec.md +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/__init__.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/base.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/cli.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/fastapi.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/jupyter.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/print.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/agui/__init__.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/agui/__main__.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/compat.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/__init__.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/agent.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/stub.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/events.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/__init__.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/base.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/builtins.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/interrupts.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/messages.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/__init__.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/messages.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/updates.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/__init__.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/__main__.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/config.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/loader.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/workspace.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/parser.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/resume.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/__init__.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/fixtures/__init__.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/fixtures/mocks.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_agui.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_agui_matrix.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_cli_adapter.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_compat.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_demo.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_demo_stub.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_dual_mode.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_events.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_extractors.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_fastapi_adapter.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_generic_extractor.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_host.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_host_config.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_jupyter.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_lc14_compat.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_parser.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_print_adapter.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_real_model.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_reasoning_display.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_resume.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_session_adapter.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_subagent.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_v2_stream.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_wire_contract.py +0 -0
- {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/uv.lock +0 -0
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-06-14
|
|
4
|
+
|
|
5
|
+
Make delegated tasks **interactive** — a per-task event transcript, agent
|
|
6
|
+
self-delegation tools, and talk-back. Builds on the 0.5.0 task engine.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **Event transcript per task.** `TaskRunner` now *streams* each run's events to
|
|
10
|
+
the store as they arrive (instead of draining at the end), via two new
|
|
11
|
+
`TaskStore` methods — `append_events` / `get_events`. This is what makes a
|
|
12
|
+
per-task detail/replay view (and live-tailing) possible. `InMemoryTaskStore`
|
|
13
|
+
implements both.
|
|
14
|
+
- **`TASK_TOOLS`** (`langgraph_stream_parser.tasks.tools`) — five agent-facing
|
|
15
|
+
delegation tools (`start_async_task`, `check_async_task`, `list_async_tasks`,
|
|
16
|
+
`update_async_task`, `cancel_async_task`) so an agent can spawn async copies of
|
|
17
|
+
itself against the local runner (no remote server). Mirrors the deepagents
|
|
18
|
+
async-subagent contract.
|
|
19
|
+
- **`current_task_id`** context var — set while a task's agent runs, so a
|
|
20
|
+
sub-task it spawns is automatically linked to it (`parent_id`), forming a tree.
|
|
21
|
+
- **`TaskRunner.followup(task_id, message)`** — send a follow-up to a finished
|
|
22
|
+
task; continues its thread (it remembers prior work) and re-runs in the
|
|
23
|
+
background. `TaskRunner.store` property for read access from tools.
|
|
24
|
+
|
|
25
|
+
### Notes
|
|
26
|
+
- Additive; the 0.5.0 API is unchanged. The streaming rewrite preserves the
|
|
27
|
+
same terminal-outcome → board-state mapping and cancel/shutdown semantics
|
|
28
|
+
(regression-tested).
|
|
29
|
+
|
|
30
|
+
## [0.5.0] - 2026-06-14
|
|
31
|
+
|
|
32
|
+
An **async task-delegation engine** — the reusable core behind a "delegate a
|
|
33
|
+
task, it runs in the background, track it on a board" surface. Single-process,
|
|
34
|
+
dependency-free; surfaces provide a concrete store.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- **`langgraph_stream_parser.tasks`** — `TaskRunner` (an asyncio worker pool that
|
|
38
|
+
drives the shared `SessionAdapter`), a `TaskStore` protocol with a dependency-free
|
|
39
|
+
`InMemoryTaskStore` reference impl, the `Task` record + `TaskState` machine
|
|
40
|
+
(`queued → ongoing → review_needed → done/failed/cancelled`), and
|
|
41
|
+
`set_runner`/`get_runner` so agent tools can reach the runner.
|
|
42
|
+
`enqueue()` returns a `task_id` immediately (non-blocking); workers run each
|
|
43
|
+
task as its own session and transition it by the run's outcome; `cancel`,
|
|
44
|
+
`resume` (HITL), and `retry` round out the controls; orphaned `ongoing` tasks
|
|
45
|
+
are requeued on `start()`.
|
|
46
|
+
- **`Session.outcome`** (+ `Session.interrupt` / `Session.error`) — a typed
|
|
47
|
+
terminal-outcome signal set by the session adapter
|
|
48
|
+
(`complete | interrupted | error | cancelled`). Headless consumers read this
|
|
49
|
+
instead of re-inspecting the event stream. Correctly distinguishes a HITL
|
|
50
|
+
pause from completion (the parser always emits a trailing `CompleteEvent`,
|
|
51
|
+
even after an interrupt).
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
- `__version__` was stale at `0.2.1` (pyproject was already ahead); now `0.5.0`.
|
|
55
|
+
|
|
56
|
+
### Notes
|
|
57
|
+
- Additive and dependency-free. The engine depends only on the `TaskStore`
|
|
58
|
+
protocol and the public `SessionAdapter` surface, so a surface can back it
|
|
59
|
+
with any store (in-memory, SQLite, …) and later graduate to a remote Agent
|
|
60
|
+
Protocol server without changing the engine.
|
|
61
|
+
|
|
3
62
|
## [0.4.1] - 2026-06-14
|
|
4
63
|
|
|
5
64
|
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:
|
|
@@ -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.6.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.6.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,18 @@ 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
|
+
TASK_TOOLS,
|
|
67
|
+
set_runner,
|
|
68
|
+
get_runner,
|
|
69
|
+
current_task_id,
|
|
70
|
+
outcome_to_state,
|
|
71
|
+
)
|
|
60
72
|
from .compat import (
|
|
61
73
|
stream_graph_updates,
|
|
62
74
|
astream_graph_updates,
|
|
@@ -64,7 +76,7 @@ from .compat import (
|
|
|
64
76
|
aresume_graph_from_interrupt,
|
|
65
77
|
)
|
|
66
78
|
|
|
67
|
-
__version__ = "0.
|
|
79
|
+
__version__ = "0.6.0"
|
|
68
80
|
|
|
69
81
|
__all__ = [
|
|
70
82
|
# Main parser
|
|
@@ -98,6 +110,17 @@ __all__ = [
|
|
|
98
110
|
"load_agent_spec",
|
|
99
111
|
"HostConfig",
|
|
100
112
|
"Workspace",
|
|
113
|
+
# Task-delegation engine
|
|
114
|
+
"TaskRunner",
|
|
115
|
+
"TaskStore",
|
|
116
|
+
"InMemoryTaskStore",
|
|
117
|
+
"Task",
|
|
118
|
+
"TaskState",
|
|
119
|
+
"TASK_TOOLS",
|
|
120
|
+
"set_runner",
|
|
121
|
+
"get_runner",
|
|
122
|
+
"current_task_id",
|
|
123
|
+
"outcome_to_state",
|
|
101
124
|
# Serialization
|
|
102
125
|
"event_to_dict",
|
|
103
126
|
# 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) ──────────────────────────────────────────────
|
|
@@ -0,0 +1,57 @@
|
|
|
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, current_task_id, get_runner, set_runner
|
|
24
|
+
from .tools import TASK_TOOLS
|
|
25
|
+
from .state import (
|
|
26
|
+
CANCELLED,
|
|
27
|
+
DONE,
|
|
28
|
+
FAILED,
|
|
29
|
+
ONGOING,
|
|
30
|
+
QUEUED,
|
|
31
|
+
REVIEW_NEEDED,
|
|
32
|
+
TERMINAL_STATES,
|
|
33
|
+
TaskState,
|
|
34
|
+
outcome_to_state,
|
|
35
|
+
)
|
|
36
|
+
from .store import InMemoryTaskStore, Task, TaskStore, now_iso
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"TaskRunner",
|
|
40
|
+
"set_runner",
|
|
41
|
+
"get_runner",
|
|
42
|
+
"current_task_id",
|
|
43
|
+
"TASK_TOOLS",
|
|
44
|
+
"TaskStore",
|
|
45
|
+
"InMemoryTaskStore",
|
|
46
|
+
"Task",
|
|
47
|
+
"now_iso",
|
|
48
|
+
"TaskState",
|
|
49
|
+
"outcome_to_state",
|
|
50
|
+
"TERMINAL_STATES",
|
|
51
|
+
"QUEUED",
|
|
52
|
+
"ONGOING",
|
|
53
|
+
"REVIEW_NEEDED",
|
|
54
|
+
"DONE",
|
|
55
|
+
"FAILED",
|
|
56
|
+
"CANCELLED",
|
|
57
|
+
]
|
|
@@ -0,0 +1,342 @@
|
|
|
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 contextvars
|
|
25
|
+
import logging
|
|
26
|
+
import uuid
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
from .state import (
|
|
30
|
+
CANCELLED,
|
|
31
|
+
DONE,
|
|
32
|
+
FAILED,
|
|
33
|
+
ONGOING,
|
|
34
|
+
QUEUED,
|
|
35
|
+
REVIEW_NEEDED,
|
|
36
|
+
TERMINAL_STATES,
|
|
37
|
+
outcome_to_state,
|
|
38
|
+
)
|
|
39
|
+
from .store import Task, TaskStore, now_iso
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
#: Set to the running task's id while its agent executes, so delegation tools
|
|
44
|
+
#: called by that agent can record the spawned sub-task's ``parent_id``.
|
|
45
|
+
#: ``asyncio.create_task`` copies the context, so the value propagates into the
|
|
46
|
+
#: agent's run task and the tools it invokes.
|
|
47
|
+
current_task_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
48
|
+
"langstage_current_task_id", default=None
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
#: Event ``type`` values that end a run's stream (the parser emits exactly one
|
|
52
|
+
#: per turn; an interrupt is followed by a trailing ``complete``).
|
|
53
|
+
_TERMINAL_EVENT_TYPES = frozenset({"complete", "error", "cancelled"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TaskRunner:
|
|
57
|
+
"""Runs delegated tasks on the app's asyncio loop. See module docstring."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
adapter: Any,
|
|
62
|
+
store: TaskStore,
|
|
63
|
+
*,
|
|
64
|
+
concurrency: int = 3,
|
|
65
|
+
thread_prefix: str = "task-",
|
|
66
|
+
context_label: str = "Async task",
|
|
67
|
+
poll_interval: float = 2.0,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._adapter = adapter
|
|
70
|
+
self._store = store
|
|
71
|
+
self._concurrency = max(1, concurrency)
|
|
72
|
+
self._thread_prefix = thread_prefix
|
|
73
|
+
self._context_label = context_label
|
|
74
|
+
self._poll_interval = poll_interval
|
|
75
|
+
self._workers: list[asyncio.Task] = []
|
|
76
|
+
self._side_tasks: set[asyncio.Task] = set() # resume runs, etc.
|
|
77
|
+
self._wake = asyncio.Event()
|
|
78
|
+
self._started = False
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def store(self) -> TaskStore:
|
|
82
|
+
"""The task store backing this runner (for read access from tools)."""
|
|
83
|
+
return self._store
|
|
84
|
+
|
|
85
|
+
# ── lifecycle ────────────────────────────────────────────────────
|
|
86
|
+
async def start(self) -> None:
|
|
87
|
+
"""Set up the store, recover orphans, and spawn worker coroutines."""
|
|
88
|
+
if self._started:
|
|
89
|
+
return
|
|
90
|
+
await self._store.setup()
|
|
91
|
+
recovered = await self._store.requeue_orphans()
|
|
92
|
+
if recovered:
|
|
93
|
+
logger.info("TaskRunner requeued %d orphaned task(s) on startup", recovered)
|
|
94
|
+
self._started = True
|
|
95
|
+
self._workers = [
|
|
96
|
+
asyncio.create_task(self._worker(i)) for i in range(self._concurrency)
|
|
97
|
+
]
|
|
98
|
+
# Kick the workers in case there's already queued work.
|
|
99
|
+
self._wake.set()
|
|
100
|
+
|
|
101
|
+
async def shutdown(self) -> None:
|
|
102
|
+
tasks = [*self._workers, *self._side_tasks]
|
|
103
|
+
for t in tasks:
|
|
104
|
+
t.cancel()
|
|
105
|
+
# Await the cancellations so no tasks linger past shutdown (otherwise
|
|
106
|
+
# the event loop can't close cleanly on the way down).
|
|
107
|
+
if tasks:
|
|
108
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
109
|
+
self._workers.clear()
|
|
110
|
+
self._side_tasks.clear()
|
|
111
|
+
self._started = False
|
|
112
|
+
|
|
113
|
+
# ── public API ───────────────────────────────────────────────────
|
|
114
|
+
async def enqueue(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
title: str,
|
|
118
|
+
prompt: str,
|
|
119
|
+
agent_spec: Optional[str] = None,
|
|
120
|
+
parent_id: Optional[str] = None,
|
|
121
|
+
) -> str:
|
|
122
|
+
"""Create a queued task and return its id immediately (non-blocking)."""
|
|
123
|
+
if not prompt or not prompt.strip():
|
|
124
|
+
raise ValueError("Task prompt is required.")
|
|
125
|
+
task_id = uuid.uuid4().hex
|
|
126
|
+
task: Task = {
|
|
127
|
+
"task_id": task_id,
|
|
128
|
+
"parent_id": parent_id,
|
|
129
|
+
"title": (title or prompt).strip()[:200],
|
|
130
|
+
"prompt": prompt.strip(),
|
|
131
|
+
"agent_spec": agent_spec,
|
|
132
|
+
"state": QUEUED,
|
|
133
|
+
"thread_id": f"{self._thread_prefix}{task_id}",
|
|
134
|
+
"created_at": now_iso(),
|
|
135
|
+
"started_at": None,
|
|
136
|
+
"finished_at": None,
|
|
137
|
+
"result": None,
|
|
138
|
+
"artifacts": None,
|
|
139
|
+
"error": None,
|
|
140
|
+
"interrupt": None,
|
|
141
|
+
}
|
|
142
|
+
await self._store.create(task)
|
|
143
|
+
self._wake.set()
|
|
144
|
+
return task_id
|
|
145
|
+
|
|
146
|
+
async def cancel(self, task_id: str) -> bool:
|
|
147
|
+
"""Cancel a task. Stops the in-flight run if it's ``ongoing``."""
|
|
148
|
+
task = await self._store.get(task_id)
|
|
149
|
+
if task is None or task.get("state") in TERMINAL_STATES:
|
|
150
|
+
return False
|
|
151
|
+
# Mark cancelled first, then interrupt the in-flight run. The worker
|
|
152
|
+
# awaiting that run sees the run task's CancelledError (its own
|
|
153
|
+
# ``cancelling()`` is 0) and leaves the already-set state alone.
|
|
154
|
+
await self._store.update(
|
|
155
|
+
task_id, state=CANCELLED, finished_at=now_iso()
|
|
156
|
+
)
|
|
157
|
+
if task.get("state") == ONGOING:
|
|
158
|
+
self._adapter.cancel(task["thread_id"])
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
async def resume(self, task_id: str, decisions: list[dict[str, Any]]) -> bool:
|
|
162
|
+
"""Resume a ``review_needed`` task with HITL decisions (non-blocking).
|
|
163
|
+
|
|
164
|
+
Flips the task back to ``ongoing`` and runs the resume in the
|
|
165
|
+
background so the caller (an approve/reject HTTP handler) returns at
|
|
166
|
+
once.
|
|
167
|
+
"""
|
|
168
|
+
task = await self._store.get(task_id)
|
|
169
|
+
if task is None or task.get("state") != REVIEW_NEEDED:
|
|
170
|
+
return False
|
|
171
|
+
await self._store.update(
|
|
172
|
+
task_id, state=ONGOING, interrupt=None, started_at=now_iso()
|
|
173
|
+
)
|
|
174
|
+
t = asyncio.create_task(self._resume_run(task, decisions))
|
|
175
|
+
self._side_tasks.add(t)
|
|
176
|
+
t.add_done_callback(self._side_tasks.discard)
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
async def followup(self, task_id: str, message: str) -> bool:
|
|
180
|
+
"""Send a follow-up message to a finished task's thread (talk-back).
|
|
181
|
+
|
|
182
|
+
Continues the conversation on the same thread (the checkpointer keeps
|
|
183
|
+
history), runs in the background, and flips the task to ``ongoing`` →
|
|
184
|
+
``done`` with the new reply. For a ``review_needed`` task use
|
|
185
|
+
:meth:`resume` instead. Non-blocking.
|
|
186
|
+
"""
|
|
187
|
+
if not message or not message.strip():
|
|
188
|
+
return False
|
|
189
|
+
task = await self._store.get(task_id)
|
|
190
|
+
if task is None or task.get("state") not in {DONE, FAILED, CANCELLED}:
|
|
191
|
+
return False
|
|
192
|
+
await self._store.update(
|
|
193
|
+
task_id, state=ONGOING, finished_at=None, error=None, started_at=now_iso()
|
|
194
|
+
)
|
|
195
|
+
t = asyncio.create_task(self._followup_run(task, message.strip()))
|
|
196
|
+
self._side_tasks.add(t)
|
|
197
|
+
t.add_done_callback(self._side_tasks.discard)
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
async def _followup_run(self, task: Task, message: str) -> None:
|
|
201
|
+
task_id = task["task_id"]
|
|
202
|
+
token = current_task_id.set(task_id)
|
|
203
|
+
try:
|
|
204
|
+
session = self._adapter.submit_message(task["thread_id"], message)
|
|
205
|
+
finally:
|
|
206
|
+
current_task_id.reset(token)
|
|
207
|
+
await self._await_run(task_id, session)
|
|
208
|
+
|
|
209
|
+
async def retry(self, task_id: str) -> bool:
|
|
210
|
+
"""Re-queue a ``failed`` / ``cancelled`` task (same thread → resumes
|
|
211
|
+
from its last checkpoint where a durable saver is configured)."""
|
|
212
|
+
task = await self._store.get(task_id)
|
|
213
|
+
if task is None or task.get("state") not in {FAILED, CANCELLED}:
|
|
214
|
+
return False
|
|
215
|
+
await self._store.update(
|
|
216
|
+
task_id,
|
|
217
|
+
state=QUEUED,
|
|
218
|
+
error=None,
|
|
219
|
+
finished_at=None,
|
|
220
|
+
started_at=None,
|
|
221
|
+
)
|
|
222
|
+
self._wake.set()
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
# ── internals ────────────────────────────────────────────────────
|
|
226
|
+
async def _worker(self, idx: int) -> None:
|
|
227
|
+
while True:
|
|
228
|
+
try:
|
|
229
|
+
task = await self._store.claim_next()
|
|
230
|
+
except Exception: # pragma: no cover - defensive
|
|
231
|
+
logger.exception("worker %d: claim_next failed", idx)
|
|
232
|
+
task = None
|
|
233
|
+
if task is None:
|
|
234
|
+
# Nothing to do: sleep until woken or the poll timeout fires
|
|
235
|
+
# (the timeout self-heals any missed wake signal).
|
|
236
|
+
try:
|
|
237
|
+
await asyncio.wait_for(self._wake.wait(), timeout=self._poll_interval)
|
|
238
|
+
except asyncio.TimeoutError:
|
|
239
|
+
pass
|
|
240
|
+
self._wake.clear()
|
|
241
|
+
continue
|
|
242
|
+
await self._run_one(task)
|
|
243
|
+
|
|
244
|
+
async def _run_one(self, task: Task) -> None:
|
|
245
|
+
task_id = task["task_id"]
|
|
246
|
+
# Tag the context so any sub-task the agent spawns records this as parent.
|
|
247
|
+
token = current_task_id.set(task_id)
|
|
248
|
+
try:
|
|
249
|
+
session = self._adapter.submit_message(
|
|
250
|
+
task["thread_id"],
|
|
251
|
+
task["prompt"],
|
|
252
|
+
context_parts=[f"[{self._context_label}: {task.get('title', task_id)}]"],
|
|
253
|
+
)
|
|
254
|
+
finally:
|
|
255
|
+
current_task_id.reset(token)
|
|
256
|
+
await self._await_run(task_id, session)
|
|
257
|
+
|
|
258
|
+
async def _resume_run(
|
|
259
|
+
self, task: Task, decisions: list[dict[str, Any]]
|
|
260
|
+
) -> None:
|
|
261
|
+
task_id = task["task_id"]
|
|
262
|
+
token = current_task_id.set(task_id)
|
|
263
|
+
try:
|
|
264
|
+
session = self._adapter.submit_decisions(task["thread_id"], decisions)
|
|
265
|
+
finally:
|
|
266
|
+
current_task_id.reset(token)
|
|
267
|
+
await self._await_run(task_id, session)
|
|
268
|
+
|
|
269
|
+
async def _await_run(self, task_id: str, session: Any) -> None:
|
|
270
|
+
"""Stream the run's events to the store as they arrive, then record the
|
|
271
|
+
terminal outcome. Streaming (vs draining at the end) is what makes the
|
|
272
|
+
per-task detail/replay view possible — including live-tailing."""
|
|
273
|
+
content_parts: list[str] = []
|
|
274
|
+
q = getattr(session, "event_queue", None)
|
|
275
|
+
run_task = getattr(session, "current_task", None)
|
|
276
|
+
try:
|
|
277
|
+
if q is not None:
|
|
278
|
+
while True:
|
|
279
|
+
event = await q.get()
|
|
280
|
+
await self._store.append_events(task_id, [event])
|
|
281
|
+
if event.get("type") == "content" and event.get("role", "assistant") == "assistant":
|
|
282
|
+
content_parts.append(event.get("content", ""))
|
|
283
|
+
if event.get("type") in _TERMINAL_EVENT_TYPES:
|
|
284
|
+
break
|
|
285
|
+
if run_task is not None and not run_task.done():
|
|
286
|
+
await run_task
|
|
287
|
+
except asyncio.CancelledError:
|
|
288
|
+
cur = asyncio.current_task()
|
|
289
|
+
if cur is not None and cur.cancelling() > 0:
|
|
290
|
+
# This worker is itself being cancelled (shutdown) → propagate
|
|
291
|
+
# so it can exit; leave the task ``ongoing`` for restart recovery.
|
|
292
|
+
raise
|
|
293
|
+
# Otherwise the *run* task was cancelled via ``cancel()`` — its state
|
|
294
|
+
# is already set; persist any stragglers and stop.
|
|
295
|
+
await self._flush_remaining(task_id, q)
|
|
296
|
+
return
|
|
297
|
+
except Exception: # pragma: no cover - _produce captures its own errors
|
|
298
|
+
logger.exception("task %s run raised", task_id)
|
|
299
|
+
|
|
300
|
+
result_text = "".join(content_parts).strip() or None
|
|
301
|
+
await self._record_outcome(task_id, session, result_text)
|
|
302
|
+
|
|
303
|
+
async def _flush_remaining(self, task_id: str, q: Any) -> None:
|
|
304
|
+
if q is None:
|
|
305
|
+
return
|
|
306
|
+
leftover: list[dict[str, Any]] = []
|
|
307
|
+
while not q.empty():
|
|
308
|
+
try:
|
|
309
|
+
leftover.append(q.get_nowait())
|
|
310
|
+
except asyncio.QueueEmpty: # pragma: no cover
|
|
311
|
+
break
|
|
312
|
+
if leftover:
|
|
313
|
+
await self._store.append_events(task_id, leftover)
|
|
314
|
+
|
|
315
|
+
async def _record_outcome(
|
|
316
|
+
self, task_id: str, session: Any, result_text: Optional[str]
|
|
317
|
+
) -> None:
|
|
318
|
+
outcome = getattr(session, "outcome", None)
|
|
319
|
+
state = outcome_to_state(outcome)
|
|
320
|
+
fields: dict[str, Any] = {"state": state}
|
|
321
|
+
if state in TERMINAL_STATES:
|
|
322
|
+
fields["finished_at"] = now_iso()
|
|
323
|
+
if state == DONE:
|
|
324
|
+
fields["result"] = result_text
|
|
325
|
+
elif state == REVIEW_NEEDED:
|
|
326
|
+
fields["interrupt"] = getattr(session, "interrupt", None)
|
|
327
|
+
elif state == FAILED:
|
|
328
|
+
fields["error"] = getattr(session, "error", None) or "Task run failed."
|
|
329
|
+
await self._store.update(task_id, **fields)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ── process-global singleton (so agent tools can reach the runner) ──
|
|
333
|
+
_runner: Optional[TaskRunner] = None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def set_runner(runner: Optional[TaskRunner]) -> None:
|
|
337
|
+
global _runner
|
|
338
|
+
_runner = runner
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_runner() -> Optional[TaskRunner]:
|
|
342
|
+
return _runner
|