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.
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/CHANGELOG.md +27 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/PKG-INFO +1 -1
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/pyproject.toml +1 -1
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/__init__.py +5 -1
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/tasks/__init__.py +4 -1
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/tasks/runner.py +97 -41
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/tasks/store.py +16 -0
- langgraph_stream_parser-0.6.0/src/langgraph_stream_parser/tasks/tools.py +121 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_tasks.py +108 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/.github/workflows/ci.yml +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/.github/workflows/release.yml +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/.gitignore +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/LICENSE +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/README.md +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/assets/header.svg +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/docs/adr/0001-adopt-ag-ui-for-the-wire.md +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/examples/agent.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/examples/fastapi_websocket.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/examples/jupyter_example.ipynb +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/spec.md +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/__init__.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/base.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/cli.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/fastapi.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/jupyter.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/print.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/adapters/session.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/agui/__init__.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/agui/__main__.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/compat.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/__init__.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/agent.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/demo/stub.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/events.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/__init__.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/base.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/builtins.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/interrupts.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/extractors/messages.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/__init__.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/messages.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/handlers/updates.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/__init__.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/__main__.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/config.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/loader.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/host/workspace.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/parser.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/resume.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/src/langgraph_stream_parser/tasks/state.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/__init__.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/fixtures/__init__.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/fixtures/mocks.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_agui.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_agui_matrix.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_cli_adapter.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_compat.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_demo.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_demo_stub.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_dual_mode.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_events.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_extractors.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_fastapi_adapter.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_generic_extractor.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_host.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_host_config.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_jupyter.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_lc14_compat.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_parser.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_print_adapter.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_real_model.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_reasoning_display.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_resume.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_session_adapter.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_subagent.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_v2_stream.py +0 -0
- {langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_wire_contract.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
#
|
|
226
|
-
self.
|
|
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
|
-
|
|
232
|
-
await self._record_outcome(task_id, session,
|
|
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,
|
|
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"] =
|
|
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()
|
|
File without changes
|
{langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/.github/workflows/release.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/examples/fastapi_websocket.py
RENAMED
|
File without changes
|
{langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/examples/jupyter_example.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_fastapi_adapter.py
RENAMED
|
File without changes
|
{langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_generic_extractor.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_reasoning_display.py
RENAMED
|
File without changes
|
|
File without changes
|
{langgraph_stream_parser-0.5.0 → langgraph_stream_parser-0.6.0}/tests/test_session_adapter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|