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.
Files changed (77) hide show
  1. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/.github/workflows/ci.yml +1 -1
  2. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/CHANGELOG.md +45 -0
  3. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/PKG-INFO +3 -2
  4. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/pyproject.toml +3 -2
  5. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/__init__.py +20 -1
  6. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/session.py +47 -5
  7. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/agui/__init__.py +54 -4
  8. langgraph_stream_parser-0.5.0/src/langgraph_stream_parser/tasks/__init__.py +54 -0
  9. langgraph_stream_parser-0.5.0/src/langgraph_stream_parser/tasks/runner.py +286 -0
  10. langgraph_stream_parser-0.5.0/src/langgraph_stream_parser/tasks/state.py +49 -0
  11. langgraph_stream_parser-0.5.0/src/langgraph_stream_parser/tasks/store.py +151 -0
  12. langgraph_stream_parser-0.5.0/tests/test_agui_matrix.py +261 -0
  13. langgraph_stream_parser-0.5.0/tests/test_tasks.py +421 -0
  14. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/.github/workflows/release.yml +0 -0
  15. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/.gitignore +0 -0
  16. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/LICENSE +0 -0
  17. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/README.md +0 -0
  18. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/assets/header.svg +0 -0
  19. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/docs/adr/0001-adopt-ag-ui-for-the-wire.md +0 -0
  20. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/examples/agent.py +0 -0
  21. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/examples/fastapi_websocket.py +0 -0
  22. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/examples/jupyter_example.ipynb +0 -0
  23. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/spec.md +0 -0
  24. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/__init__.py +0 -0
  25. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/base.py +0 -0
  26. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/cli.py +0 -0
  27. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/fastapi.py +0 -0
  28. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/jupyter.py +0 -0
  29. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/adapters/print.py +0 -0
  30. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/agui/__main__.py +0 -0
  31. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/compat.py +0 -0
  32. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/demo/__init__.py +0 -0
  33. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/demo/agent.py +0 -0
  34. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/demo/stub.py +0 -0
  35. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/events.py +0 -0
  36. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/extractors/__init__.py +0 -0
  37. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/extractors/base.py +0 -0
  38. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/extractors/builtins.py +0 -0
  39. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/extractors/interrupts.py +0 -0
  40. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/extractors/messages.py +0 -0
  41. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/handlers/__init__.py +0 -0
  42. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/handlers/messages.py +0 -0
  43. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/handlers/updates.py +0 -0
  44. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/host/__init__.py +0 -0
  45. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/host/__main__.py +0 -0
  46. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/host/config.py +0 -0
  47. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/host/loader.py +0 -0
  48. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/host/workspace.py +0 -0
  49. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/parser.py +0 -0
  50. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/src/langgraph_stream_parser/resume.py +0 -0
  51. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/__init__.py +0 -0
  52. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/fixtures/__init__.py +0 -0
  53. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/fixtures/mocks.py +0 -0
  54. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_agui.py +0 -0
  55. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_cli_adapter.py +0 -0
  56. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_compat.py +0 -0
  57. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_demo.py +0 -0
  58. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_demo_stub.py +0 -0
  59. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_dual_mode.py +0 -0
  60. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_events.py +0 -0
  61. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_extractors.py +0 -0
  62. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_fastapi_adapter.py +0 -0
  63. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_generic_extractor.py +0 -0
  64. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_host.py +0 -0
  65. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_host_config.py +0 -0
  66. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_jupyter.py +0 -0
  67. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_lc14_compat.py +0 -0
  68. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_parser.py +0 -0
  69. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_print_adapter.py +0 -0
  70. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_real_model.py +0 -0
  71. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_reasoning_display.py +0 -0
  72. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_resume.py +0 -0
  73. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_session_adapter.py +0 -0
  74. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_subagent.py +0 -0
  75. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_v2_stream.py +0 -0
  76. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/tests/test_wire_contract.py +0 -0
  77. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.5.0}/uv.lock +0 -0
@@ -13,7 +13,7 @@ jobs:
13
13
  fail-fast: false
14
14
  matrix:
15
15
  os: [ubuntu-latest]
16
- python-version: ["3.11", "3.12", "3.13"]
16
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
17
17
  include:
18
18
  # One Windows job for cross-platform coverage (primary dev env).
19
19
  - os: windows-latest
@@ -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.0
4
- Summary: Universal parser + host layer for LangGraph agents — typed stream events, agent-spec loading, layered config, and an AG-UI bridge
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.0"
4
- description = "Universal parser + host layer for LangGraph agents — typed stream events, agent-spec loading, layered config, and an AG-UI bridge"
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.2.1"
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
- session.push(event_to_dict(event, max_result_len=self._max_result_len))
205
- # Terminal events end the turn; the parser emits exactly one.
206
- if isinstance(event, (CompleteEvent, ErrorEvent)):
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 ag_ui_langgraph import add_langgraph_fastapi_endpoint
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
- add_langgraph_fastapi_endpoint(app, agent, path=path)
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