langgraph-stream-parser 0.5.0__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.5.0 → langgraph_stream_parser-0.6.0}/CHANGELOG.md +27 -0
  2. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/PKG-INFO +1 -1
  3. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/pyproject.toml +1 -1
  4. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/__init__.py +5 -1
  5. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/tasks/__init__.py +4 -1
  6. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/tasks/runner.py +97 -41
  7. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/tasks/store.py +16 -0
  8. langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/tools.py +121 -0
  9. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_tasks.py +108 -0
  10. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/.github/workflows/ci.yml +0 -0
  11. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/.github/workflows/release.yml +0 -0
  12. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/.gitignore +0 -0
  13. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/LICENSE +0 -0
  14. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/README.md +0 -0
  15. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/assets/header.svg +0 -0
  16. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/docs/adr/0001-adopt-ag-ui-for-the-wire.md +0 -0
  17. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/examples/agent.py +0 -0
  18. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/examples/fastapi_websocket.py +0 -0
  19. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/examples/jupyter_example.ipynb +0 -0
  20. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/spec.md +0 -0
  21. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/__init__.py +0 -0
  22. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/base.py +0 -0
  23. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/cli.py +0 -0
  24. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/fastapi.py +0 -0
  25. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/jupyter.py +0 -0
  26. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/print.py +0 -0
  27. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/session.py +0 -0
  28. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/agui/__init__.py +0 -0
  29. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/agui/__main__.py +0 -0
  30. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/compat.py +0 -0
  31. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/__init__.py +0 -0
  32. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/agent.py +0 -0
  33. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/stub.py +0 -0
  34. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/events.py +0 -0
  35. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/__init__.py +0 -0
  36. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/base.py +0 -0
  37. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/builtins.py +0 -0
  38. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/interrupts.py +0 -0
  39. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/messages.py +0 -0
  40. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/__init__.py +0 -0
  41. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/messages.py +0 -0
  42. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/updates.py +0 -0
  43. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/__init__.py +0 -0
  44. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/__main__.py +0 -0
  45. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/config.py +0 -0
  46. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/loader.py +0 -0
  47. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/workspace.py +0 -0
  48. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/parser.py +0 -0
  49. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/resume.py +0 -0
  50. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/tasks/state.py +0 -0
  51. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/__init__.py +0 -0
  52. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/fixtures/__init__.py +0 -0
  53. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/fixtures/mocks.py +0 -0
  54. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_agui.py +0 -0
  55. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_agui_matrix.py +0 -0
  56. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_cli_adapter.py +0 -0
  57. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_compat.py +0 -0
  58. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_demo.py +0 -0
  59. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_demo_stub.py +0 -0
  60. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_dual_mode.py +0 -0
  61. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_events.py +0 -0
  62. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_extractors.py +0 -0
  63. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_fastapi_adapter.py +0 -0
  64. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_generic_extractor.py +0 -0
  65. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_host.py +0 -0
  66. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_host_config.py +0 -0
  67. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_jupyter.py +0 -0
  68. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_lc14_compat.py +0 -0
  69. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_parser.py +0 -0
  70. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_print_adapter.py +0 -0
  71. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_real_model.py +0 -0
  72. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_reasoning_display.py +0 -0
  73. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_resume.py +0 -0
  74. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_session_adapter.py +0 -0
  75. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_subagent.py +0 -0
  76. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_v2_stream.py +0 -0
  77. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_wire_contract.py +0 -0
  78. {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/uv.lock +0 -0
@@ -1,5 +1,32 @@
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
+
3
30
  ## [0.5.0] - 2026-06-14
4
31
 
5
32
  An **async task-delegation engine** — the reusable core behind a "delegate a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-stream-parser
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "langgraph-stream-parser"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
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"}
@@ -63,8 +63,10 @@ from .tasks import (
63
63
  InMemoryTaskStore,
64
64
  Task,
65
65
  TaskState,
66
+ TASK_TOOLS,
66
67
  set_runner,
67
68
  get_runner,
69
+ current_task_id,
68
70
  outcome_to_state,
69
71
  )
70
72
  from .compat import (
@@ -74,7 +76,7 @@ from .compat import (
74
76
  aresume_graph_from_interrupt,
75
77
  )
76
78
 
77
- __version__ = "0.5.0"
79
+ __version__ = "0.6.0"
78
80
 
79
81
  __all__ = [
80
82
  # Main parser
@@ -114,8 +116,10 @@ __all__ = [
114
116
  "InMemoryTaskStore",
115
117
  "Task",
116
118
  "TaskState",
119
+ "TASK_TOOLS",
117
120
  "set_runner",
118
121
  "get_runner",
122
+ "current_task_id",
119
123
  "outcome_to_state",
120
124
  # Serialization
121
125
  "event_to_dict",
@@ -20,7 +20,8 @@ Example:
20
20
  """
21
21
  from __future__ import annotations
22
22
 
23
- from .runner import TaskRunner, get_runner, set_runner
23
+ from .runner import TaskRunner, current_task_id, get_runner, set_runner
24
+ from .tools import TASK_TOOLS
24
25
  from .state import (
25
26
  CANCELLED,
26
27
  DONE,
@@ -38,6 +39,8 @@ __all__ = [
38
39
  "TaskRunner",
39
40
  "set_runner",
40
41
  "get_runner",
42
+ "current_task_id",
43
+ "TASK_TOOLS",
41
44
  "TaskStore",
42
45
  "InMemoryTaskStore",
43
46
  "Task",
@@ -21,6 +21,7 @@ atomic claim is only atomic within one process — run one server worker.
21
21
  from __future__ import annotations
22
22
 
23
23
  import asyncio
24
+ import contextvars
24
25
  import logging
25
26
  import uuid
26
27
  from typing import Any, Optional
@@ -39,6 +40,18 @@ from .store import Task, TaskStore, now_iso
39
40
 
40
41
  logger = logging.getLogger(__name__)
41
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
+
42
55
 
43
56
  class TaskRunner:
44
57
  """Runs delegated tasks on the app's asyncio loop. See module docstring."""
@@ -64,6 +77,11 @@ class TaskRunner:
64
77
  self._wake = asyncio.Event()
65
78
  self._started = False
66
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
+
67
85
  # ── lifecycle ────────────────────────────────────────────────────
68
86
  async def start(self) -> None:
69
87
  """Set up the store, recover orphans, and spawn worker coroutines."""
@@ -158,6 +176,36 @@ class TaskRunner:
158
176
  t.add_done_callback(self._side_tasks.discard)
159
177
  return True
160
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
+
161
209
  async def retry(self, task_id: str) -> bool:
162
210
  """Re-queue a ``failed`` / ``cancelled`` task (same thread → resumes
163
211
  from its last checkpoint where a durable saver is configured)."""
@@ -195,25 +243,46 @@ class TaskRunner:
195
243
 
196
244
  async def _run_one(self, task: Task) -> None:
197
245
  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
- )
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)
203
256
  await self._await_run(task_id, session)
204
257
 
205
258
  async def _resume_run(
206
259
  self, task: Task, decisions: list[dict[str, Any]]
207
260
  ) -> None:
208
261
  task_id = task["task_id"]
209
- session = self._adapter.submit_decisions(task["thread_id"], decisions)
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)
210
267
  await self._await_run(task_id, session)
211
268
 
212
269
  async def _await_run(self, task_id: str, session: Any) -> None:
213
- """Await a session's in-flight turn and record the outcome."""
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)
214
276
  try:
215
- run_task = getattr(session, "current_task", None)
216
- if run_task is not None:
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():
217
286
  await run_task
218
287
  except asyncio.CancelledError:
219
288
  cur = asyncio.current_task()
@@ -221,18 +290,30 @@ class TaskRunner:
221
290
  # This worker is itself being cancelled (shutdown) → propagate
222
291
  # so it can exit; leave the task ``ongoing`` for restart recovery.
223
292
  raise
224
- # Otherwise the *run* task was cancelled via ``cancel()`` — its
225
- # state is already set; just drain and stop.
226
- self._drain(session)
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)
227
296
  return
228
297
  except Exception: # pragma: no cover - _produce captures its own errors
229
298
  logger.exception("task %s run raised", task_id)
230
299
 
231
- events = self._drain(session)
232
- await self._record_outcome(task_id, session, events)
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)
233
314
 
234
315
  async def _record_outcome(
235
- self, task_id: str, session: Any, events: list[dict[str, Any]]
316
+ self, task_id: str, session: Any, result_text: Optional[str]
236
317
  ) -> None:
237
318
  outcome = getattr(session, "outcome", None)
238
319
  state = outcome_to_state(outcome)
@@ -240,38 +321,13 @@ class TaskRunner:
240
321
  if state in TERMINAL_STATES:
241
322
  fields["finished_at"] = now_iso()
242
323
  if state == DONE:
243
- fields["result"] = self._result_text(events)
324
+ fields["result"] = result_text
244
325
  elif state == REVIEW_NEEDED:
245
326
  fields["interrupt"] = getattr(session, "interrupt", None)
246
327
  elif state == FAILED:
247
328
  fields["error"] = getattr(session, "error", None) or "Task run failed."
248
329
  await self._store.update(task_id, **fields)
249
330
 
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
331
 
276
332
  # ── process-global singleton (so agent tools can reach the runner) ──
277
333
  _runner: Optional[TaskRunner] = None
@@ -85,6 +85,15 @@ class TaskStore(Protocol):
85
85
  recover from a crash). Returns the number requeued."""
86
86
  ...
87
87
 
88
+ async def append_events(self, task_id: str, events: list[dict[str, Any]]) -> None:
89
+ """Append serialized stream events to a task's transcript (the live
90
+ stream the runner produces). Enables a detail/replay view per task."""
91
+ ...
92
+
93
+ async def get_events(self, task_id: str) -> list[dict[str, Any]]:
94
+ """Return a task's full event transcript in order (empty if none)."""
95
+ ...
96
+
88
97
 
89
98
  class InMemoryTaskStore:
90
99
  """Dependency-free reference store. Not durable across process restarts.
@@ -95,6 +104,7 @@ class InMemoryTaskStore:
95
104
 
96
105
  def __init__(self) -> None:
97
106
  self._tasks: dict[str, Task] = {}
107
+ self._events: dict[str, list[dict[str, Any]]] = {}
98
108
  self._lock = asyncio.Lock()
99
109
 
100
110
  async def setup(self) -> None:
@@ -149,3 +159,9 @@ class InMemoryTaskStore:
149
159
  task["started_at"] = None
150
160
  n += 1
151
161
  return n
162
+
163
+ async def append_events(self, task_id: str, events: list[dict[str, Any]]) -> None:
164
+ self._events.setdefault(task_id, []).extend(events)
165
+
166
+ async def get_events(self, task_id: str) -> list[dict[str, Any]]:
167
+ return list(self._events.get(task_id, []))
@@ -0,0 +1,121 @@
1
+ """Agent-facing delegation tools — let an agent spawn async copies of itself.
2
+
3
+ These mirror the deepagents async-subagent tool contract, but run against the
4
+ LOCAL :class:`TaskRunner` + store (no remote Agent Protocol server). The agent
5
+ gets a ``task_id`` back immediately and should NOT poll — the task runs in the
6
+ background while the agent keeps working.
7
+
8
+ Wire them into a host's toolset alongside its other tools; they reach the
9
+ process-global runner via :func:`get_runner`, so a sub-task the agent spawns is
10
+ automatically linked to the spawning task (``parent_id``) via the
11
+ ``current_task_id`` context var.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from langchain_core.tools import tool as langchain_tool
16
+
17
+ from .runner import current_task_id, get_runner
18
+
19
+ _UNAVAILABLE = "Async task delegation is unavailable in this context."
20
+
21
+
22
+ @langchain_tool
23
+ async def start_async_task(title: str, prompt: str) -> str:
24
+ """Delegate a task to a background copy of yourself.
25
+
26
+ Returns a task_id immediately; the task runs in the background while you
27
+ keep working. Do NOT wait or poll — report the task_id to the user and move
28
+ on. Use this for long or parallelizable work.
29
+
30
+ Args:
31
+ title: A short human-readable name for the task.
32
+ prompt: The full instruction for the background agent to carry out.
33
+ """
34
+ runner = get_runner()
35
+ if runner is None:
36
+ return _UNAVAILABLE
37
+ try:
38
+ task_id = await runner.enqueue(
39
+ title=title, prompt=prompt, parent_id=current_task_id.get()
40
+ )
41
+ except ValueError as e:
42
+ return f"Could not start task: {e}"
43
+ return f"Started async task '{title}'. task_id: {task_id} (running in the background)."
44
+
45
+
46
+ @langchain_tool
47
+ async def check_async_task(task_id: str) -> str:
48
+ """Check a delegated task's current status and result. Does not block.
49
+
50
+ Call this once when the user asks about a task — never poll in a loop.
51
+ """
52
+ runner = get_runner()
53
+ if runner is None:
54
+ return _UNAVAILABLE
55
+ task = await runner.store.get(task_id)
56
+ if task is None:
57
+ return f"No delegated task with id {task_id}."
58
+ state = task.get("state")
59
+ if state == "done":
60
+ return f"Task {task_id} is done.\n\nResult:\n{task.get('result') or '(no text output)'}"
61
+ if state == "failed":
62
+ return f"Task {task_id} failed: {task.get('error') or 'unknown error'}"
63
+ if state == "review_needed":
64
+ return f"Task {task_id} is paused and needs human review/approval before it can continue."
65
+ return f"Task {task_id} is {state}."
66
+
67
+
68
+ @langchain_tool
69
+ async def list_async_tasks() -> str:
70
+ """List delegated tasks and their current states."""
71
+ runner = get_runner()
72
+ if runner is None:
73
+ return _UNAVAILABLE
74
+ tasks = await runner.store.list()
75
+ if not tasks:
76
+ return "No delegated tasks."
77
+ lines = [
78
+ f"- {t['task_id']} [{t.get('state')}] {t.get('title')}" for t in tasks
79
+ ]
80
+ return "Delegated tasks:\n" + "\n".join(lines)
81
+
82
+
83
+ @langchain_tool
84
+ async def update_async_task(task_id: str, message: str) -> str:
85
+ """Send a follow-up instruction to a finished delegated task.
86
+
87
+ Continues that task's conversation on its own thread (it remembers its prior
88
+ work) and runs in the background. Use for "now also do X" on a completed task.
89
+ """
90
+ runner = get_runner()
91
+ if runner is None:
92
+ return _UNAVAILABLE
93
+ ok = await runner.followup(task_id, message)
94
+ return (
95
+ f"Sent follow-up to task {task_id} (running in the background)."
96
+ if ok
97
+ else f"Could not update task {task_id} (not found, or it is still running)."
98
+ )
99
+
100
+
101
+ @langchain_tool
102
+ async def cancel_async_task(task_id: str) -> str:
103
+ """Cancel a delegated task that is queued, running, or awaiting review."""
104
+ runner = get_runner()
105
+ if runner is None:
106
+ return _UNAVAILABLE
107
+ ok = await runner.cancel(task_id)
108
+ return (
109
+ f"Cancelled task {task_id}."
110
+ if ok
111
+ else f"Could not cancel task {task_id} (not found or already finished)."
112
+ )
113
+
114
+
115
+ TASK_TOOLS = [
116
+ start_async_task,
117
+ check_async_task,
118
+ list_async_tasks,
119
+ update_async_task,
120
+ cancel_async_task,
121
+ ]
@@ -21,10 +21,18 @@ from langgraph_stream_parser.tasks import (
21
21
  REVIEW_NEEDED,
22
22
  InMemoryTaskStore,
23
23
  TaskRunner,
24
+ current_task_id,
24
25
  get_runner,
25
26
  set_runner,
26
27
  )
27
28
  from langgraph_stream_parser.tasks.store import now_iso
29
+ from langgraph_stream_parser.tasks.tools import (
30
+ cancel_async_task,
31
+ check_async_task,
32
+ list_async_tasks,
33
+ start_async_task,
34
+ update_async_task,
35
+ )
28
36
 
29
37
  from .fixtures.mocks import (
30
38
  AI_MESSAGE_WITH_TOOL_CALLS,
@@ -419,3 +427,103 @@ class TestRunnerHardening:
419
427
  assert row["result"] # tokens concatenated into the final text
420
428
  finally:
421
429
  await runner.shutdown()
430
+
431
+
432
+ # ── 6. Slice 2: event transcript, followup, delegation tools ─────────
433
+
434
+
435
+ class TestEventTranscript:
436
+ async def test_events_streamed_to_store(self):
437
+ adapter = SessionAdapter(graph=MockGraph([[SIMPLE_AI_MESSAGE]]), stream_mode="updates")
438
+ store = InMemoryTaskStore()
439
+ runner = TaskRunner(adapter, store, concurrency=1, poll_interval=0.05)
440
+ await runner.start()
441
+ try:
442
+ tid = await runner.enqueue(title="t", prompt="hi")
443
+ await _wait_state(store, tid, DONE)
444
+ events = await store.get_events(tid)
445
+ types = [e.get("type") for e in events]
446
+ assert "content" in types
447
+ assert types[-1] == "complete" # terminal event recorded last
448
+ finally:
449
+ await runner.shutdown()
450
+
451
+ async def test_followup_reruns_thread(self):
452
+ graph = MockGraph([[SIMPLE_AI_MESSAGE], [SIMPLE_AI_MESSAGE]])
453
+ adapter = SessionAdapter(graph=graph, stream_mode="updates")
454
+ store = InMemoryTaskStore()
455
+ runner = TaskRunner(adapter, store, concurrency=1, poll_interval=0.05)
456
+ await runner.start()
457
+ try:
458
+ tid = await runner.enqueue(title="t", prompt="hi")
459
+ await _wait_state(store, tid, DONE)
460
+ assert await runner.followup(tid, "now do more") is True
461
+ await _wait_state(store, tid, DONE)
462
+ events = await store.get_events(tid)
463
+ # the thread ran twice → two terminal completes in the transcript
464
+ assert sum(1 for e in events if e.get("type") == "complete") == 2
465
+ finally:
466
+ await runner.shutdown()
467
+
468
+ async def test_followup_rejected_on_unknown(self):
469
+ store = InMemoryTaskStore()
470
+ runner = TaskRunner(SessionAdapter(graph=MockGraph([[]])), store)
471
+ assert await runner.followup("nope", "hi") is False
472
+
473
+
474
+ def _task_id_from(out: str) -> str:
475
+ return out.split("task_id:")[1].split()[0].strip().rstrip(".")
476
+
477
+
478
+ class TestDelegationTools:
479
+ async def test_start_check_list(self):
480
+ adapter = SessionAdapter(graph=MockGraph([[SIMPLE_AI_MESSAGE]]), stream_mode="updates")
481
+ store = InMemoryTaskStore()
482
+ runner = TaskRunner(adapter, store, concurrency=1, poll_interval=0.05)
483
+ await runner.start()
484
+ set_runner(runner)
485
+ try:
486
+ out = await start_async_task.ainvoke({"title": "research", "prompt": "go research"})
487
+ assert "task_id:" in out
488
+ tid = _task_id_from(out)
489
+ await _wait_state(store, tid, DONE)
490
+ assert "done" in (await check_async_task.ainvoke({"task_id": tid})).lower()
491
+ assert tid in await list_async_tasks.ainvoke({})
492
+ finally:
493
+ set_runner(None)
494
+ await runner.shutdown()
495
+
496
+ async def test_parent_id_from_context(self):
497
+ # The runner sets current_task_id while an agent runs; a tool the agent
498
+ # calls must stamp the spawned task's parent_id from it.
499
+ store = InMemoryTaskStore()
500
+ runner = TaskRunner(SessionAdapter(graph=MockGraph([[SIMPLE_AI_MESSAGE]])), store) # not started
501
+ set_runner(runner)
502
+ try:
503
+ token = current_task_id.set("parent-123")
504
+ try:
505
+ out = await start_async_task.ainvoke({"title": "child", "prompt": "p"})
506
+ finally:
507
+ current_task_id.reset(token)
508
+ child = await store.get(_task_id_from(out))
509
+ assert child["parent_id"] == "parent-123"
510
+ finally:
511
+ set_runner(None)
512
+
513
+ async def test_cancel_tool(self):
514
+ store = InMemoryTaskStore()
515
+ runner = TaskRunner(SessionAdapter(graph=MockGraph([[SIMPLE_AI_MESSAGE]])), store) # not started
516
+ set_runner(runner)
517
+ try:
518
+ tid = await runner.enqueue(title="t", prompt="p")
519
+ assert "Cancelled" in await cancel_async_task.ainvoke({"task_id": tid})
520
+ assert (await store.get(tid))["state"] == CANCELLED
521
+ finally:
522
+ set_runner(None)
523
+
524
+ async def test_tools_unavailable_without_runner(self):
525
+ set_runner(None)
526
+ assert "unavailable" in (
527
+ await start_async_task.ainvoke({"title": "x", "prompt": "p"})
528
+ ).lower()
529
+ assert "unavailable" in (await list_async_tasks.ainvoke({})).lower()