langgraph-stream-parser 0.1.5__tar.gz → 0.1.7__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.1.5 → langgraph_stream_parser-0.1.7}/.claude/settings.local.json +3 -1
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/CHANGELOG.md +24 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/PKG-INFO +65 -1
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/README.md +60 -0
- langgraph_stream_parser-0.1.7/examples/fastapi_websocket.py +234 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/pyproject.toml +6 -1
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/__init__.py +5 -1
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/__init__.py +9 -1
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/base.py +75 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/cli.py +1 -5
- langgraph_stream_parser-0.1.7/src/langgraph_stream_parser/adapters/fastapi.py +311 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/jupyter.py +3 -38
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/print.py +3 -42
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/events.py +59 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/parser.py +132 -3
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/fixtures/mocks.py +41 -0
- langgraph_stream_parser-0.1.7/tests/test_fastapi_adapter.py +398 -0
- langgraph_stream_parser-0.1.7/tests/test_v2_stream.py +633 -0
- langgraph_stream_parser-0.1.5/examples/fastapi_websocket.py +0 -455
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/.gitignore +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/LICENSE +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/examples/agent.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/examples/jupyter_example.ipynb +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/spec.md +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/compat.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/__init__.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/base.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/builtins.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/interrupts.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/messages.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/handlers/__init__.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/handlers/messages.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/handlers/updates.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/resume.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/__init__.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/fixtures/__init__.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_cli_adapter.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_compat.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_dual_mode.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_events.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_extractors.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_jupyter.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_parser.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_print_adapter.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_resume.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_subagent.py +0 -0
- {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/uv.lock +0 -0
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"Bash(python -m pytest:*)",
|
|
13
13
|
"WebFetch(domain:docs.langchain.com)",
|
|
14
14
|
"WebFetch(domain:agentclientprotocol.com)",
|
|
15
|
-
"WebFetch(domain:github.com)"
|
|
15
|
+
"WebFetch(domain:github.com)",
|
|
16
|
+
"WebSearch",
|
|
17
|
+
"Bash(find /Users/dkedar7/code/langgraph-stream-parser/src -type f -name \"*.py\" -exec wc -l {} +)"
|
|
16
18
|
]
|
|
17
19
|
}
|
|
18
20
|
}
|
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.7] - 2026-04-18
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `FastAPIAdapter` for streaming LangGraph events over WebSocket and Server-Sent Events; stateless by design — conversation state is keyed by `session_id` used as LangGraph `thread_id`
|
|
7
|
+
- Per-session asyncio lock with refcounted cleanup to serialize concurrent turns on the same thread
|
|
8
|
+
- `BaseAdapter._text_prompt_interrupt()` helper, shared by `PrintAdapter` and `JupyterDisplay`
|
|
9
|
+
- `BaseAdapter._truncate()` helper for preview-length capping
|
|
10
|
+
- `fastapi` optional dependency group (`pip install langgraph-stream-parser[fastapi]`)
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Hoisted `_last_rendered_count` incremental-render cursor from Print/CLI into `BaseAdapter`
|
|
14
|
+
- Slimmed `examples/fastapi_websocket.py` from ~455 to ~234 lines by using the new adapter
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- `UsageEvent` now has an explicit case in `BaseAdapter._process_event` instead of silently falling through
|
|
18
|
+
|
|
19
|
+
## [0.1.6] - 2026-03-28
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- v2 StreamPart parsing (`stream_mode="v2"`) with auto-detection of `{"type", "ns", "data"}` dict format
|
|
23
|
+
- `ValuesEvent` for full state snapshots from `stream_mode="values"` (v2)
|
|
24
|
+
- `DebugEvent` for debug, checkpoint, and task trace data from v2 streaming
|
|
25
|
+
- Routing for v2 stream types: updates, messages, custom, values, debug, checkpoints, tasks
|
|
26
|
+
|
|
3
27
|
## [0.1.5] - 2026-02-06
|
|
4
28
|
|
|
5
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langgraph-stream-parser
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Universal parser for LangGraph streaming outputs
|
|
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
|
|
@@ -21,12 +21,16 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
25
|
+
Requires-Dist: httpx>=0.25; extra == 'dev'
|
|
24
26
|
Requires-Dist: langchain-core>=0.2.0; extra == 'dev'
|
|
25
27
|
Requires-Dist: langgraph>=0.2.0; extra == 'dev'
|
|
26
28
|
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
27
29
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
28
30
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
29
31
|
Requires-Dist: rich>=13.0; extra == 'dev'
|
|
32
|
+
Provides-Extra: fastapi
|
|
33
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
30
34
|
Provides-Extra: jupyter
|
|
31
35
|
Requires-Dist: ipython>=8.0; extra == 'jupyter'
|
|
32
36
|
Requires-Dist: rich>=13.0; extra == 'jupyter'
|
|
@@ -342,6 +346,66 @@ adapter.run(graph=agent, input_data=input_data, config=config)
|
|
|
342
346
|
|
|
343
347
|
Universal output that works in any Python environment without dependencies.
|
|
344
348
|
|
|
349
|
+
### FastAPIAdapter - WebSocket / SSE Streaming
|
|
350
|
+
|
|
351
|
+
Stream events to a web client over WebSocket or Server-Sent Events. The adapter is stateless — conversation state lives in LangGraph's checkpointer, keyed by `session_id` (used as `thread_id`).
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
from fastapi import FastAPI, WebSocket
|
|
355
|
+
from langgraph_stream_parser.adapters import FastAPIAdapter
|
|
356
|
+
|
|
357
|
+
app = FastAPI()
|
|
358
|
+
adapter = FastAPIAdapter(graph=agent) # agent must be compiled with a checkpointer
|
|
359
|
+
|
|
360
|
+
@app.websocket("/chat/{session_id}")
|
|
361
|
+
async def chat(ws: WebSocket, session_id: str):
|
|
362
|
+
await adapter.handle_websocket(ws, session_id)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Reconnecting with the same `session_id` resumes the conversation — LangGraph's checkpointer rehydrates history automatically.
|
|
366
|
+
|
|
367
|
+
**WebSocket message protocol (client ↔ server)**
|
|
368
|
+
|
|
369
|
+
Client → Server:
|
|
370
|
+
|
|
371
|
+
```jsonc
|
|
372
|
+
{"type": "message", "content": "Hello"}
|
|
373
|
+
{"type": "decision", "decisions": [{"type": "approve"}]}
|
|
374
|
+
{"type": "decision", "decisions": [{"type": "edit", "args": {...}}]}
|
|
375
|
+
{"type": "cancel"}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Server → Client: every event's `to_dict()` output (e.g. `{"type": "content", ...}`, `{"type": "tool_start", ...}`, `{"type": "interrupt", ...}`, `{"type": "complete"}`), plus protocol-level messages:
|
|
379
|
+
|
|
380
|
+
```jsonc
|
|
381
|
+
{"type": "ack", "ref": "message|decision|cancel"}
|
|
382
|
+
{"type": "error", "error": "..."}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
**Server-Sent Events**
|
|
386
|
+
|
|
387
|
+
```python
|
|
388
|
+
from fastapi.responses import StreamingResponse
|
|
389
|
+
from langgraph_stream_parser import prepare_agent_input
|
|
390
|
+
|
|
391
|
+
@app.post("/chat/{session_id}")
|
|
392
|
+
async def chat(session_id: str, body: dict):
|
|
393
|
+
input_data = prepare_agent_input(message=body["message"])
|
|
394
|
+
return StreamingResponse(
|
|
395
|
+
adapter.sse_stream(session_id, input_data),
|
|
396
|
+
media_type="text/event-stream",
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
@app.post("/chat/{session_id}/resume")
|
|
400
|
+
async def resume(session_id: str, body: dict):
|
|
401
|
+
return StreamingResponse(
|
|
402
|
+
adapter.resume(session_id, body["decisions"]),
|
|
403
|
+
media_type="text/event-stream",
|
|
404
|
+
)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Requires: `pip install langgraph-stream-parser[fastapi]`
|
|
408
|
+
|
|
345
409
|
### JupyterDisplay - Rich Notebook Display
|
|
346
410
|
|
|
347
411
|
```python
|
|
@@ -308,6 +308,66 @@ adapter.run(graph=agent, input_data=input_data, config=config)
|
|
|
308
308
|
|
|
309
309
|
Universal output that works in any Python environment without dependencies.
|
|
310
310
|
|
|
311
|
+
### FastAPIAdapter - WebSocket / SSE Streaming
|
|
312
|
+
|
|
313
|
+
Stream events to a web client over WebSocket or Server-Sent Events. The adapter is stateless — conversation state lives in LangGraph's checkpointer, keyed by `session_id` (used as `thread_id`).
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
from fastapi import FastAPI, WebSocket
|
|
317
|
+
from langgraph_stream_parser.adapters import FastAPIAdapter
|
|
318
|
+
|
|
319
|
+
app = FastAPI()
|
|
320
|
+
adapter = FastAPIAdapter(graph=agent) # agent must be compiled with a checkpointer
|
|
321
|
+
|
|
322
|
+
@app.websocket("/chat/{session_id}")
|
|
323
|
+
async def chat(ws: WebSocket, session_id: str):
|
|
324
|
+
await adapter.handle_websocket(ws, session_id)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Reconnecting with the same `session_id` resumes the conversation — LangGraph's checkpointer rehydrates history automatically.
|
|
328
|
+
|
|
329
|
+
**WebSocket message protocol (client ↔ server)**
|
|
330
|
+
|
|
331
|
+
Client → Server:
|
|
332
|
+
|
|
333
|
+
```jsonc
|
|
334
|
+
{"type": "message", "content": "Hello"}
|
|
335
|
+
{"type": "decision", "decisions": [{"type": "approve"}]}
|
|
336
|
+
{"type": "decision", "decisions": [{"type": "edit", "args": {...}}]}
|
|
337
|
+
{"type": "cancel"}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Server → Client: every event's `to_dict()` output (e.g. `{"type": "content", ...}`, `{"type": "tool_start", ...}`, `{"type": "interrupt", ...}`, `{"type": "complete"}`), plus protocol-level messages:
|
|
341
|
+
|
|
342
|
+
```jsonc
|
|
343
|
+
{"type": "ack", "ref": "message|decision|cancel"}
|
|
344
|
+
{"type": "error", "error": "..."}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
**Server-Sent Events**
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
from fastapi.responses import StreamingResponse
|
|
351
|
+
from langgraph_stream_parser import prepare_agent_input
|
|
352
|
+
|
|
353
|
+
@app.post("/chat/{session_id}")
|
|
354
|
+
async def chat(session_id: str, body: dict):
|
|
355
|
+
input_data = prepare_agent_input(message=body["message"])
|
|
356
|
+
return StreamingResponse(
|
|
357
|
+
adapter.sse_stream(session_id, input_data),
|
|
358
|
+
media_type="text/event-stream",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
@app.post("/chat/{session_id}/resume")
|
|
362
|
+
async def resume(session_id: str, body: dict):
|
|
363
|
+
return StreamingResponse(
|
|
364
|
+
adapter.resume(session_id, body["decisions"]),
|
|
365
|
+
media_type="text/event-stream",
|
|
366
|
+
)
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Requires: `pip install langgraph-stream-parser[fastapi]`
|
|
370
|
+
|
|
311
371
|
### JupyterDisplay - Rich Notebook Display
|
|
312
372
|
|
|
313
373
|
```python
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI WebSocket Example using FastAPIAdapter.
|
|
3
|
+
|
|
4
|
+
Demonstrates the built-in ``FastAPIAdapter`` streaming LangGraph events
|
|
5
|
+
over a WebSocket with interrupt handling. The adapter is stateless —
|
|
6
|
+
conversation state lives in LangGraph's checkpointer keyed by
|
|
7
|
+
``session_id`` (used as ``thread_id``).
|
|
8
|
+
|
|
9
|
+
Requirements:
|
|
10
|
+
pip install 'langgraph-stream-parser[fastapi]' uvicorn langgraph
|
|
11
|
+
|
|
12
|
+
Run:
|
|
13
|
+
uvicorn examples.fastapi_websocket:app --reload
|
|
14
|
+
|
|
15
|
+
Then open http://localhost:8000 in your browser.
|
|
16
|
+
"""
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
from fastapi import FastAPI, WebSocket
|
|
20
|
+
from fastapi.responses import HTMLResponse
|
|
21
|
+
from dotenv import load_dotenv
|
|
22
|
+
|
|
23
|
+
from langgraph_stream_parser.adapters import FastAPIAdapter
|
|
24
|
+
|
|
25
|
+
load_dotenv()
|
|
26
|
+
|
|
27
|
+
from .agent import agent
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
app = FastAPI()
|
|
31
|
+
adapter = FastAPIAdapter(graph=agent)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.websocket("/ws/chat/{session_id}")
|
|
35
|
+
async def websocket_chat(websocket: WebSocket, session_id: str):
|
|
36
|
+
"""WebSocket endpoint. Reconnecting with the same session_id resumes
|
|
37
|
+
the conversation (state lives in the checkpointer)."""
|
|
38
|
+
await adapter.handle_websocket(websocket, session_id)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.get("/")
|
|
42
|
+
async def get_client():
|
|
43
|
+
"""Serve the HTML test client, seeded with a fresh session_id."""
|
|
44
|
+
return HTMLResponse(HTML_CLIENT.replace("{{SESSION_ID}}", str(uuid.uuid4())))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── HTML client ─────────────────────────────────────────────────────
|
|
48
|
+
# The protocol: client sends
|
|
49
|
+
# {"type": "message", "content": "..."}
|
|
50
|
+
# {"type": "decision", "decisions": [{"type": "approve"}, ...]}
|
|
51
|
+
# Server sends: event_to_dict(event) for each StreamEvent, plus
|
|
52
|
+
# {"type": "ack", "ref": "..."} and {"type": "error", "error": "..."}
|
|
53
|
+
|
|
54
|
+
HTML_CLIENT = """
|
|
55
|
+
<!DOCTYPE html>
|
|
56
|
+
<html>
|
|
57
|
+
<head>
|
|
58
|
+
<title>LangGraph Stream Parser - WebSocket Demo</title>
|
|
59
|
+
<style>
|
|
60
|
+
* { box-sizing: border-box; }
|
|
61
|
+
body {
|
|
62
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
63
|
+
max-width: 800px;
|
|
64
|
+
margin: 0 auto;
|
|
65
|
+
padding: 20px;
|
|
66
|
+
background: #f5f5f5;
|
|
67
|
+
}
|
|
68
|
+
h1 { color: #333; }
|
|
69
|
+
#chat {
|
|
70
|
+
background: white;
|
|
71
|
+
border: 1px solid #ddd;
|
|
72
|
+
border-radius: 8px;
|
|
73
|
+
height: 500px;
|
|
74
|
+
overflow-y: auto;
|
|
75
|
+
padding: 16px;
|
|
76
|
+
margin-bottom: 16px;
|
|
77
|
+
}
|
|
78
|
+
.message { margin: 8px 0; padding: 12px; border-radius: 8px; }
|
|
79
|
+
.user { background: #007bff; color: white; margin-left: 20%; }
|
|
80
|
+
.assistant { background: #e9ecef; margin-right: 20%; }
|
|
81
|
+
.tool-start { background: #fff3cd; border-left: 4px solid #ffc107; font-size: 0.9em; }
|
|
82
|
+
.tool-end { background: #d4edda; border-left: 4px solid #28a745; font-size: 0.9em; }
|
|
83
|
+
.tool-error { background: #f8d7da; border-left: 4px solid #dc3545; }
|
|
84
|
+
.complete { background: #d1ecf1; border-left: 4px solid #17a2b8; text-align: center; font-style: italic; }
|
|
85
|
+
.error { background: #f8d7da; border-left: 4px solid #dc3545; }
|
|
86
|
+
.interrupt { background: #fff3cd; border: 2px solid #ffc107; padding: 16px; }
|
|
87
|
+
.interrupt h4 { margin: 0 0 12px 0; color: #856404; }
|
|
88
|
+
.interrupt pre { background: #f8f9fa; padding: 8px; border-radius: 4px; overflow-x: auto; font-size: 0.85em; }
|
|
89
|
+
.interrupt-buttons { margin-top: 12px; display: flex; gap: 8px; }
|
|
90
|
+
.interrupt-buttons button { padding: 8px 16px; font-size: 14px; }
|
|
91
|
+
.btn-approve { background: #28a745; }
|
|
92
|
+
.btn-reject { background: #dc3545; }
|
|
93
|
+
#input-area { display: flex; gap: 8px; }
|
|
94
|
+
#message-input { flex: 1; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; }
|
|
95
|
+
button { padding: 12px 24px; background: #007bff; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; }
|
|
96
|
+
button:disabled { background: #6c757d; cursor: not-allowed; }
|
|
97
|
+
code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; font-family: Monaco, Consolas, monospace; }
|
|
98
|
+
.session { color: #666; font-size: 0.85em; }
|
|
99
|
+
</style>
|
|
100
|
+
</head>
|
|
101
|
+
<body>
|
|
102
|
+
<h1>LangGraph Stream Parser Demo</h1>
|
|
103
|
+
<p class="session">Session: <code>{{SESSION_ID}}</code></p>
|
|
104
|
+
|
|
105
|
+
<div id="chat"></div>
|
|
106
|
+
|
|
107
|
+
<div id="input-area">
|
|
108
|
+
<input type="text" id="message-input" placeholder="Type a message..." />
|
|
109
|
+
<button id="send-btn" onclick="sendMessage()">Send</button>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<script>
|
|
113
|
+
const sessionId = "{{SESSION_ID}}";
|
|
114
|
+
const chat = document.getElementById('chat');
|
|
115
|
+
const input = document.getElementById('message-input');
|
|
116
|
+
const sendBtn = document.getElementById('send-btn');
|
|
117
|
+
|
|
118
|
+
let ws = null;
|
|
119
|
+
let currentAssistantMessage = null;
|
|
120
|
+
|
|
121
|
+
function connect() {
|
|
122
|
+
ws = new WebSocket(`ws://${window.location.host}/ws/chat/${sessionId}`);
|
|
123
|
+
ws.onopen = () => addMessage('system', 'Connected.');
|
|
124
|
+
ws.onmessage = (event) => handleEvent(JSON.parse(event.data));
|
|
125
|
+
ws.onclose = () => {
|
|
126
|
+
addMessage('error', 'Disconnected. Refresh to reconnect.');
|
|
127
|
+
sendBtn.disabled = true;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function handleEvent(data) {
|
|
132
|
+
switch (data.type) {
|
|
133
|
+
case 'ack':
|
|
134
|
+
if (data.ref === 'message') sendBtn.disabled = true;
|
|
135
|
+
break;
|
|
136
|
+
case 'content':
|
|
137
|
+
if (!currentAssistantMessage) {
|
|
138
|
+
currentAssistantMessage = addMessage('assistant', '');
|
|
139
|
+
}
|
|
140
|
+
currentAssistantMessage.textContent += data.content;
|
|
141
|
+
break;
|
|
142
|
+
case 'tool_start':
|
|
143
|
+
addMessage('tool-start',
|
|
144
|
+
`🔧 Calling <code>${data.name}</code> with: ${JSON.stringify(data.args)}`);
|
|
145
|
+
break;
|
|
146
|
+
case 'tool_end':
|
|
147
|
+
const cls = data.status === 'success' ? 'tool-end' : 'tool-error';
|
|
148
|
+
const icon = data.status === 'success' ? '✓' : '✗';
|
|
149
|
+
addMessage(cls, `${icon} <code>${data.name}</code>: ${data.result}`);
|
|
150
|
+
break;
|
|
151
|
+
case 'complete':
|
|
152
|
+
addMessage('complete', '— Response complete —');
|
|
153
|
+
sendBtn.disabled = false;
|
|
154
|
+
currentAssistantMessage = null;
|
|
155
|
+
break;
|
|
156
|
+
case 'error':
|
|
157
|
+
addMessage('error', `Error: ${data.error}`);
|
|
158
|
+
sendBtn.disabled = false;
|
|
159
|
+
break;
|
|
160
|
+
case 'interrupt':
|
|
161
|
+
showInterrupt(data);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
chat.scrollTop = chat.scrollHeight;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function showInterrupt(data) {
|
|
168
|
+
const div = document.createElement('div');
|
|
169
|
+
div.className = 'message interrupt';
|
|
170
|
+
|
|
171
|
+
let actionsHtml = '';
|
|
172
|
+
for (const action of data.action_requests) {
|
|
173
|
+
const tool = action.tool || action.action?.tool || 'unknown';
|
|
174
|
+
const args = action.args || action.action?.args || {};
|
|
175
|
+
actionsHtml += `<p><strong>Tool:</strong> <code>${tool}</code></p>`;
|
|
176
|
+
actionsHtml += `<pre>${JSON.stringify(args, null, 2)}</pre>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const allowed = data.allowed_decisions || ['approve', 'reject'];
|
|
180
|
+
let buttonsHtml = '<div class="interrupt-buttons">';
|
|
181
|
+
if (allowed.includes('approve')) {
|
|
182
|
+
buttonsHtml += `<button class="btn-approve" onclick="sendDecision('approve')">Approve</button>`;
|
|
183
|
+
}
|
|
184
|
+
if (allowed.includes('reject')) {
|
|
185
|
+
buttonsHtml += `<button class="btn-reject" onclick="sendDecision('reject')">Reject</button>`;
|
|
186
|
+
}
|
|
187
|
+
buttonsHtml += '</div>';
|
|
188
|
+
|
|
189
|
+
div.innerHTML = `<h4>Action Required</h4>${actionsHtml}${buttonsHtml}`;
|
|
190
|
+
chat.appendChild(div);
|
|
191
|
+
chat.scrollTop = chat.scrollHeight;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sendDecision(decisionType) {
|
|
195
|
+
if (!ws) return;
|
|
196
|
+
document.querySelectorAll('.interrupt-buttons').forEach(el => el.remove());
|
|
197
|
+
ws.send(JSON.stringify({
|
|
198
|
+
type: 'decision',
|
|
199
|
+
decisions: [{ type: decisionType }],
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function addMessage(type, content) {
|
|
204
|
+
const div = document.createElement('div');
|
|
205
|
+
div.className = `message ${type}`;
|
|
206
|
+
div.innerHTML = content;
|
|
207
|
+
chat.appendChild(div);
|
|
208
|
+
chat.scrollTop = chat.scrollHeight;
|
|
209
|
+
return div;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sendMessage() {
|
|
213
|
+
const message = input.value.trim();
|
|
214
|
+
if (!message || !ws) return;
|
|
215
|
+
addMessage('user', message);
|
|
216
|
+
ws.send(JSON.stringify({ type: 'message', content: message }));
|
|
217
|
+
input.value = '';
|
|
218
|
+
sendBtn.disabled = true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
input.addEventListener('keypress', (e) => {
|
|
222
|
+
if (e.key === 'Enter') sendMessage();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
connect();
|
|
226
|
+
</script>
|
|
227
|
+
</body>
|
|
228
|
+
</html>
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
import uvicorn
|
|
234
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "langgraph-stream-parser"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.7"
|
|
4
4
|
description = "Universal parser for LangGraph streaming outputs"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -35,6 +35,9 @@ jupyter = [
|
|
|
35
35
|
"rich>=13.0",
|
|
36
36
|
"ipython>=8.0",
|
|
37
37
|
]
|
|
38
|
+
fastapi = [
|
|
39
|
+
"fastapi>=0.100",
|
|
40
|
+
]
|
|
38
41
|
dev = [
|
|
39
42
|
"pytest>=7.0",
|
|
40
43
|
"pytest-asyncio>=0.21",
|
|
@@ -42,6 +45,8 @@ dev = [
|
|
|
42
45
|
"langgraph>=0.2.0",
|
|
43
46
|
"langchain-core>=0.2.0",
|
|
44
47
|
"rich>=13.0",
|
|
48
|
+
"fastapi>=0.100",
|
|
49
|
+
"httpx>=0.25",
|
|
45
50
|
]
|
|
46
51
|
|
|
47
52
|
[project.urls]
|
|
@@ -39,6 +39,8 @@ from .events import (
|
|
|
39
39
|
StateUpdateEvent,
|
|
40
40
|
UsageEvent,
|
|
41
41
|
CustomEvent,
|
|
42
|
+
ValuesEvent,
|
|
43
|
+
DebugEvent,
|
|
42
44
|
CompleteEvent,
|
|
43
45
|
ErrorEvent,
|
|
44
46
|
StreamEvent,
|
|
@@ -54,7 +56,7 @@ from .compat import (
|
|
|
54
56
|
aresume_graph_from_interrupt,
|
|
55
57
|
)
|
|
56
58
|
|
|
57
|
-
__version__ = "0.1.
|
|
59
|
+
__version__ = "0.1.7"
|
|
58
60
|
|
|
59
61
|
__all__ = [
|
|
60
62
|
# Main parser
|
|
@@ -68,6 +70,8 @@ __all__ = [
|
|
|
68
70
|
"StateUpdateEvent",
|
|
69
71
|
"UsageEvent",
|
|
70
72
|
"CustomEvent",
|
|
73
|
+
"ValuesEvent",
|
|
74
|
+
"DebugEvent",
|
|
71
75
|
"CompleteEvent",
|
|
72
76
|
"ErrorEvent",
|
|
73
77
|
"StreamEvent",
|
|
@@ -3,5 +3,13 @@
|
|
|
3
3
|
from .base import BaseAdapter, ToolStatus, ToolState
|
|
4
4
|
from .print import PrintAdapter
|
|
5
5
|
from .cli import CLIAdapter
|
|
6
|
+
from .fastapi import FastAPIAdapter
|
|
6
7
|
|
|
7
|
-
__all__ = [
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseAdapter",
|
|
10
|
+
"ToolStatus",
|
|
11
|
+
"ToolState",
|
|
12
|
+
"PrintAdapter",
|
|
13
|
+
"CLIAdapter",
|
|
14
|
+
"FastAPIAdapter",
|
|
15
|
+
]
|
|
@@ -18,7 +18,10 @@ from ..events import (
|
|
|
18
18
|
ToolExtractedEvent,
|
|
19
19
|
InterruptEvent,
|
|
20
20
|
StateUpdateEvent,
|
|
21
|
+
UsageEvent,
|
|
21
22
|
CustomEvent,
|
|
23
|
+
ValuesEvent,
|
|
24
|
+
DebugEvent,
|
|
22
25
|
CompleteEvent,
|
|
23
26
|
ErrorEvent,
|
|
24
27
|
)
|
|
@@ -140,6 +143,12 @@ class BaseAdapter(ABC):
|
|
|
140
143
|
self._error: ErrorEvent | None = None
|
|
141
144
|
self._complete: bool = False
|
|
142
145
|
|
|
146
|
+
# Incremental-render cursor — the render() implementations that
|
|
147
|
+
# emit chunks as they arrive (PrintAdapter, CLIAdapter) use this
|
|
148
|
+
# to slice _display_items and only render what's new. Jupyter
|
|
149
|
+
# re-renders the full list each time, so it ignores this.
|
|
150
|
+
self._last_rendered_count: int = 0
|
|
151
|
+
|
|
143
152
|
def reset(self) -> None:
|
|
144
153
|
"""Reset state for a new stream."""
|
|
145
154
|
self._display_items.clear()
|
|
@@ -149,6 +158,7 @@ class BaseAdapter(ABC):
|
|
|
149
158
|
self._interrupt = None
|
|
150
159
|
self._error = None
|
|
151
160
|
self._complete = False
|
|
161
|
+
self._last_rendered_count = 0
|
|
152
162
|
|
|
153
163
|
def run(
|
|
154
164
|
self,
|
|
@@ -314,9 +324,22 @@ class BaseAdapter(ABC):
|
|
|
314
324
|
case CustomEvent():
|
|
315
325
|
self._display_items.append(("custom", event))
|
|
316
326
|
|
|
327
|
+
case ValuesEvent():
|
|
328
|
+
self._display_items.append(("values", event))
|
|
329
|
+
|
|
330
|
+
case DebugEvent():
|
|
331
|
+
pass # Ignored in display by default
|
|
332
|
+
|
|
317
333
|
case StateUpdateEvent():
|
|
318
334
|
pass # Ignored in display
|
|
319
335
|
|
|
336
|
+
case UsageEvent():
|
|
337
|
+
pass # Ignored in display — subclasses may override
|
|
338
|
+
|
|
339
|
+
case _:
|
|
340
|
+
# Any future event types fall through here.
|
|
341
|
+
pass
|
|
342
|
+
|
|
320
343
|
# Helper methods for subclasses
|
|
321
344
|
|
|
322
345
|
def get_allowed_decisions(self, event: InterruptEvent) -> set[str]:
|
|
@@ -356,6 +379,58 @@ class BaseAdapter(ABC):
|
|
|
356
379
|
|
|
357
380
|
return decisions
|
|
358
381
|
|
|
382
|
+
def _text_prompt_interrupt(
|
|
383
|
+
self,
|
|
384
|
+
event: InterruptEvent,
|
|
385
|
+
) -> list[dict[str, Any]] | None:
|
|
386
|
+
"""Shared ``input()``-based interrupt prompt.
|
|
387
|
+
|
|
388
|
+
Used by PrintAdapter and JupyterDisplay — both prompt the user
|
|
389
|
+
via ``input()`` for a decision string and, if "edit" is chosen,
|
|
390
|
+
a JSON args object. CLIAdapter has its own arrow-key variant
|
|
391
|
+
and does not call this.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
Decision list for ``create_resume_input(decisions=...)``,
|
|
395
|
+
or None if the user cancelled.
|
|
396
|
+
"""
|
|
397
|
+
import json as _json
|
|
398
|
+
|
|
399
|
+
allowed = self.get_allowed_decisions(event)
|
|
400
|
+
options = sorted(allowed)
|
|
401
|
+
options_str = "/".join(options)
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
response = input(f"Decision ({options_str}): ").strip().lower()
|
|
405
|
+
except (EOFError, KeyboardInterrupt):
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
if not response or response not in allowed:
|
|
409
|
+
response = "reject" if "reject" in allowed else options[0]
|
|
410
|
+
|
|
411
|
+
args_modifier = None
|
|
412
|
+
if response == "edit":
|
|
413
|
+
try:
|
|
414
|
+
new_args_str = input("New args (JSON): ").strip()
|
|
415
|
+
if new_args_str:
|
|
416
|
+
new_args = _json.loads(new_args_str)
|
|
417
|
+
args_modifier = lambda _: new_args # noqa: E731
|
|
418
|
+
except (EOFError, KeyboardInterrupt, _json.JSONDecodeError):
|
|
419
|
+
response = "reject"
|
|
420
|
+
|
|
421
|
+
return self.build_decisions(event, response, args_modifier)
|
|
422
|
+
|
|
423
|
+
def _truncate(self, s: str, limit: int | None = None) -> str:
|
|
424
|
+
"""Truncate a string to ``max_content_preview`` (or ``limit``).
|
|
425
|
+
|
|
426
|
+
Used by adapters to cap preview sizes without each one
|
|
427
|
+
re-implementing the same slice-and-ellipsis logic.
|
|
428
|
+
"""
|
|
429
|
+
cap = limit if limit is not None else self._max_content_preview
|
|
430
|
+
if len(s) > cap:
|
|
431
|
+
return s[:cap] + "..."
|
|
432
|
+
return s
|
|
433
|
+
|
|
359
434
|
@staticmethod
|
|
360
435
|
def format_duration(duration_ms: float | None) -> str:
|
|
361
436
|
"""Format duration for display."""
|
|
@@ -128,7 +128,6 @@ class CLIAdapter(BaseAdapter):
|
|
|
128
128
|
)
|
|
129
129
|
self._use_spinner = use_spinner
|
|
130
130
|
self._use_colors = use_colors
|
|
131
|
-
self._last_rendered_count = 0
|
|
132
131
|
self._spinner: Spinner | None = None
|
|
133
132
|
self._active_tools: set[str] = set()
|
|
134
133
|
|
|
@@ -139,7 +138,6 @@ class CLIAdapter(BaseAdapter):
|
|
|
139
138
|
def reset(self) -> None:
|
|
140
139
|
"""Reset state for a new stream."""
|
|
141
140
|
super().reset()
|
|
142
|
-
self._last_rendered_count = 0
|
|
143
141
|
self._stop_spinner()
|
|
144
142
|
self._active_tools.clear()
|
|
145
143
|
|
|
@@ -420,9 +418,7 @@ class CLIAdapter(BaseAdapter):
|
|
|
420
418
|
"""Print extracted content with styled formatting."""
|
|
421
419
|
c = self._c
|
|
422
420
|
|
|
423
|
-
data_str = str(event.data)
|
|
424
|
-
if len(data_str) > self._max_content_preview:
|
|
425
|
-
data_str = data_str[:self._max_content_preview] + "..."
|
|
421
|
+
data_str = self._truncate(str(event.data))
|
|
426
422
|
|
|
427
423
|
# Special handling for todo types
|
|
428
424
|
if event.extracted_type in self._todo_types and isinstance(event.data, list):
|