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.
Files changed (78) hide show
  1. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/.github/workflows/ci.yml +1 -1
  2. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/CHANGELOG.md +59 -0
  3. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/PKG-INFO +3 -2
  4. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/pyproject.toml +3 -2
  5. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/__init__.py +24 -1
  6. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/session.py +47 -5
  7. langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/__init__.py +57 -0
  8. langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/runner.py +342 -0
  9. langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/state.py +49 -0
  10. langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/store.py +167 -0
  11. langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/tools.py +121 -0
  12. langgraph_stream_parser-0.6.0/tests/test_tasks.py +529 -0
  13. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/.github/workflows/release.yml +0 -0
  14. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/.gitignore +0 -0
  15. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/LICENSE +0 -0
  16. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/README.md +0 -0
  17. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/assets/header.svg +0 -0
  18. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/docs/adr/0001-adopt-ag-ui-for-the-wire.md +0 -0
  19. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/examples/agent.py +0 -0
  20. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/examples/fastapi_websocket.py +0 -0
  21. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/examples/jupyter_example.ipynb +0 -0
  22. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/spec.md +0 -0
  23. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/__init__.py +0 -0
  24. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/base.py +0 -0
  25. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/cli.py +0 -0
  26. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/fastapi.py +0 -0
  27. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/jupyter.py +0 -0
  28. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/print.py +0 -0
  29. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/agui/__init__.py +0 -0
  30. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/agui/__main__.py +0 -0
  31. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/compat.py +0 -0
  32. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/__init__.py +0 -0
  33. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/agent.py +0 -0
  34. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/stub.py +0 -0
  35. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/events.py +0 -0
  36. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/__init__.py +0 -0
  37. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/base.py +0 -0
  38. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/builtins.py +0 -0
  39. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/interrupts.py +0 -0
  40. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/messages.py +0 -0
  41. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/__init__.py +0 -0
  42. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/messages.py +0 -0
  43. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/updates.py +0 -0
  44. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/__init__.py +0 -0
  45. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/__main__.py +0 -0
  46. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/config.py +0 -0
  47. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/loader.py +0 -0
  48. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/workspace.py +0 -0
  49. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/parser.py +0 -0
  50. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/resume.py +0 -0
  51. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/__init__.py +0 -0
  52. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/fixtures/__init__.py +0 -0
  53. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/fixtures/mocks.py +0 -0
  54. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_agui.py +0 -0
  55. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_agui_matrix.py +0 -0
  56. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_cli_adapter.py +0 -0
  57. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_compat.py +0 -0
  58. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_demo.py +0 -0
  59. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_demo_stub.py +0 -0
  60. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_dual_mode.py +0 -0
  61. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_events.py +0 -0
  62. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_extractors.py +0 -0
  63. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_fastapi_adapter.py +0 -0
  64. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_generic_extractor.py +0 -0
  65. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_host.py +0 -0
  66. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_host_config.py +0 -0
  67. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_jupyter.py +0 -0
  68. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_lc14_compat.py +0 -0
  69. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_parser.py +0 -0
  70. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_print_adapter.py +0 -0
  71. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_real_model.py +0 -0
  72. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_reasoning_display.py +0 -0
  73. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_resume.py +0 -0
  74. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_session_adapter.py +0 -0
  75. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_subagent.py +0 -0
  76. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_v2_stream.py +0 -0
  77. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.0}/tests/test_wire_contract.py +0 -0
  78. {langgraph_stream_parser-0.4.1 → langgraph_stream_parser-0.6.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,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.1
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.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.1"
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.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.2.1"
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
- 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) ──────────────────────────────────────────────
@@ -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