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.
Files changed (47) hide show
  1. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/.claude/settings.local.json +3 -1
  2. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/CHANGELOG.md +24 -0
  3. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/PKG-INFO +65 -1
  4. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/README.md +60 -0
  5. langgraph_stream_parser-0.1.7/examples/fastapi_websocket.py +234 -0
  6. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/pyproject.toml +6 -1
  7. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/__init__.py +5 -1
  8. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/__init__.py +9 -1
  9. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/base.py +75 -0
  10. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/cli.py +1 -5
  11. langgraph_stream_parser-0.1.7/src/langgraph_stream_parser/adapters/fastapi.py +311 -0
  12. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/jupyter.py +3 -38
  13. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/adapters/print.py +3 -42
  14. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/events.py +59 -0
  15. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/parser.py +132 -3
  16. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/fixtures/mocks.py +41 -0
  17. langgraph_stream_parser-0.1.7/tests/test_fastapi_adapter.py +398 -0
  18. langgraph_stream_parser-0.1.7/tests/test_v2_stream.py +633 -0
  19. langgraph_stream_parser-0.1.5/examples/fastapi_websocket.py +0 -455
  20. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/.gitignore +0 -0
  21. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/LICENSE +0 -0
  22. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/examples/agent.py +0 -0
  23. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/examples/jupyter_example.ipynb +0 -0
  24. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/spec.md +0 -0
  25. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/compat.py +0 -0
  26. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/__init__.py +0 -0
  27. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/base.py +0 -0
  28. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/builtins.py +0 -0
  29. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/interrupts.py +0 -0
  30. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/extractors/messages.py +0 -0
  31. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/handlers/__init__.py +0 -0
  32. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/handlers/messages.py +0 -0
  33. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/handlers/updates.py +0 -0
  34. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/src/langgraph_stream_parser/resume.py +0 -0
  35. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/__init__.py +0 -0
  36. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/fixtures/__init__.py +0 -0
  37. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_cli_adapter.py +0 -0
  38. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_compat.py +0 -0
  39. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_dual_mode.py +0 -0
  40. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_events.py +0 -0
  41. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_extractors.py +0 -0
  42. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_jupyter.py +0 -0
  43. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_parser.py +0 -0
  44. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_print_adapter.py +0 -0
  45. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_resume.py +0 -0
  46. {langgraph_stream_parser-0.1.5 → langgraph_stream_parser-0.1.7}/tests/test_subagent.py +0 -0
  47. {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.5
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.5"
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.5"
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__ = ["BaseAdapter", "ToolStatus", "ToolState", "PrintAdapter", "CLIAdapter"]
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):