graphharbor 0.13.0.post35__tar.gz → 0.13.0.post36__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 (22) hide show
  1. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/PKG-INFO +2 -2
  2. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/pyproject.toml +2 -2
  3. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/protocol_api.py +40 -17
  4. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_stream_heartbeat_resilience.py +92 -0
  5. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/.gitignore +0 -0
  6. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/LICENSE +0 -0
  7. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/README.md +0 -0
  8. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/__init__.py +0 -0
  9. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/__main__.py +0 -0
  10. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/cli.py +0 -0
  11. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/core_api.py +0 -0
  12. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/mcp_transport.py +0 -0
  13. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/server.py +0 -0
  14. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/store_api.py +0 -0
  15. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/streaming.py +0 -0
  16. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_application_authorization.py +0 -0
  17. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_cli.py +0 -0
  18. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_graph_discovery.py +0 -0
  19. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_mcp_transport.py +0 -0
  20. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_official_protocol_compare.py +0 -0
  21. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_server_paths.py +0 -0
  22. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_thread_state_projection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphharbor
3
- Version: 0.13.0.post35
3
+ Version: 0.13.0.post36
4
4
  Summary: GraphHarbor CLI for a self-hosted LangGraph Agent Server on PostgreSQL and Redis
5
5
  Project-URL: Homepage, https://github.com/ljxpython/graphharbor
6
6
  Project-URL: Repository, https://github.com/ljxpython/graphharbor
@@ -26,7 +26,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
26
  Classifier: Typing :: Typed
27
27
  Requires-Python: >=3.11
28
28
  Requires-Dist: click>=8.1.7
29
- Requires-Dist: graphharbor-runtime==0.13.0.post35
29
+ Requires-Dist: graphharbor-runtime==0.13.0.post36
30
30
  Requires-Dist: langgraph-cli<0.5,>=0.4.0
31
31
  Requires-Dist: mcp<2,>=1.23
32
32
  Requires-Dist: pyfiglet>=1.0.0
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "graphharbor"
3
3
  # Lockstep with graphharbor-runtime; independent of langgraph-api releases.
4
- version = "0.13.0.post35"
4
+ version = "0.13.0.post36"
5
5
  description = "GraphHarbor CLI for a self-hosted LangGraph Agent Server on PostgreSQL and Redis"
6
6
  readme = "README.md"
7
7
  license = "MIT"
@@ -50,7 +50,7 @@ dependencies = [
50
50
  "mcp>=1.23,<2",
51
51
  "uvicorn[standard]>=0.51.0",
52
52
  # Exact same version as graphharbor-runtime (lockstep releases).
53
- "graphharbor-runtime==0.13.0.post35",
53
+ "graphharbor-runtime==0.13.0.post36",
54
54
  ]
55
55
 
56
56
  [project.urls]
@@ -264,10 +264,15 @@ async def _load_protocol_events(thread_id: UUID, since: int) -> tuple[int, list[
264
264
 
265
265
 
266
266
  def _wire_matches(wire: dict[str, Any], body: dict[str, Any]) -> bool:
267
- method = str(wire.get("method", ""))
268
- params = wire.get("params") or {}
267
+ if not isinstance(wire, dict):
268
+ return False
269
+ method = str(wire.get("method") or "")
270
+ params = wire.get("params")
271
+ params_dict = params if isinstance(params, dict) else {}
272
+ ns = params_dict.get("namespace")
273
+ ns_list = list(ns) if isinstance(ns, (list, tuple)) else []
269
274
  return _channel_matches(method, body.get("channels")) and _namespace_matches(
270
- list(params.get("namespace") or []), body.get("namespaces"), body.get("depth")
275
+ ns_list, body.get("namespaces"), body.get("depth")
271
276
  )
272
277
 
273
278
 
@@ -321,36 +326,54 @@ async def protocol_event_stream(request: Request) -> JSONResponse | StreamingRes
321
326
  {"code": "cursor_expired", "detail": "cursor_expired", "recovery": "thread_snapshot"},
322
327
  status_code=410,
323
328
  )
324
- parsed_run_ids = {
325
- uid
326
- for wire in replay
327
- if (uid := _safe_uuid(wire.get("params", {}).get("run_id"))) is not None
328
- }
329
+ parsed_run_ids: set[UUID] = set()
330
+ for wire in replay:
331
+ if isinstance(wire, dict):
332
+ params = wire.get("params")
333
+ if isinstance(params, dict):
334
+ uid = _safe_uuid(params.get("run_id"))
335
+ if uid is not None:
336
+ parsed_run_ids.add(uid)
329
337
  resumable = await _resumable_run_ids(parsed_run_ids)
330
338
  active_interrupt_ids = (
331
339
  set(thread.interrupts.keys()) if isinstance(thread.interrupts, dict) else set()
332
340
  )
333
341
 
334
- def _is_active_protocol_event(w: dict[str, Any]) -> bool:
335
- method = w.get("method")
336
- params = w.get("params") or {}
337
- data = params.get("data") or {}
342
+ def _is_active_protocol_event(w: Any) -> bool:
343
+ if not isinstance(w, dict):
344
+ return True
345
+ method = str(w.get("method") or "")
346
+ params = w.get("params")
347
+ params_dict = params if isinstance(params, dict) else {}
348
+ data = params_dict.get("data")
349
+ data_dict = data if isinstance(data, dict) else {}
350
+ data_event = str(data_dict.get("event") or "")
351
+
338
352
  is_input_req = (
339
353
  method == "input.requested"
340
- or (method == "input" and data.get("event") == "requested")
341
- or data.get("event") == "input.requested"
354
+ or (method == "input" and data_event == "requested")
355
+ or data_event == "input.requested"
342
356
  )
343
357
  if is_input_req:
344
- iid = str(data.get("interrupt_id") or params.get("interrupt_id") or "")
358
+ iid = str(data_dict.get("interrupt_id") or params_dict.get("interrupt_id") or "")
345
359
  if iid and iid not in active_interrupt_ids:
346
360
  return False
347
361
  return True
348
362
 
363
+ def _is_resumable(wire: Any) -> bool:
364
+ if not isinstance(wire, dict):
365
+ return True
366
+ params = wire.get("params")
367
+ if isinstance(params, dict):
368
+ uid = _safe_uuid(params.get("run_id"))
369
+ if uid is not None and uid not in resumable:
370
+ return False
371
+ return True
372
+
349
373
  replay = [
350
374
  wire
351
375
  for wire in replay
352
- if ((uid := _safe_uuid(wire.get("params", {}).get("run_id"))) is None or uid in resumable)
353
- and _is_active_protocol_event(wire)
376
+ if _is_resumable(wire) and _is_active_protocol_event(wire)
354
377
  ]
355
378
  except Exception:
356
379
  await manager.remove_thread_stream(thread_id, queue)
@@ -342,3 +342,95 @@ async def test_zombie_interrupt_replay_filtered(monkeypatch):
342
342
  assert "int-old" not in all_output, "Zombie interrupt 'int-old' must NOT be replayed to client!"
343
343
  assert "int-active" in all_output, "Active interrupt 'int-active' must be replayed to client!"
344
344
 
345
+
346
+ @pytest.mark.asyncio
347
+ async def test_protocol_stream_replay_with_non_dict_data_payloads(monkeypatch):
348
+ """Ensure protocol stream replay handles events where data is a list, string, or params is malformed."""
349
+ thread_id = uuid4()
350
+ run_id = uuid4()
351
+
352
+ mock_thread = ThreadRow(
353
+ thread_id=thread_id,
354
+ status="busy",
355
+ metadata_={},
356
+ config={},
357
+ values_={},
358
+ interrupts={},
359
+ error=None,
360
+ )
361
+
362
+ manager = InMemoryStreamManager()
363
+ monkeypatch.setattr("langhost.protocol_api.get_stream_manager", lambda: manager)
364
+ monkeypatch.setattr(
365
+ "langhost.protocol_api._thread",
366
+ lambda *a, **k: asyncio.sleep(0, result=mock_thread),
367
+ )
368
+ monkeypatch.setattr(
369
+ "langhost.streaming._resumable_run_ids",
370
+ lambda rids: asyncio.sleep(0, result=rids),
371
+ )
372
+
373
+ # Various weird payloads that occur in real production (list data, none params, string data)
374
+ wire_list_data = {
375
+ "type": "event",
376
+ "event_id": "evt-list",
377
+ "seq": 1,
378
+ "method": "messages",
379
+ "params": {
380
+ "run_id": str(run_id),
381
+ "thread_id": str(thread_id),
382
+ "namespace": [],
383
+ "data": [{"delta": {"type": "block-delta", "text": "hello"}}],
384
+ },
385
+ }
386
+ wire_string_data = {
387
+ "type": "event",
388
+ "event_id": "evt-str",
389
+ "seq": 2,
390
+ "method": "custom",
391
+ "params": {
392
+ "run_id": str(run_id),
393
+ "thread_id": str(thread_id),
394
+ "namespace": [],
395
+ "data": "plain string",
396
+ },
397
+ }
398
+ wire_none_params = {
399
+ "type": "event",
400
+ "event_id": "evt-none-params",
401
+ "seq": 3,
402
+ "method": "custom",
403
+ "params": None,
404
+ }
405
+
406
+ monkeypatch.setattr(
407
+ "langhost.protocol_api._load_protocol_events",
408
+ lambda *a, **k: asyncio.sleep(
409
+ 0, result=(0, [wire_list_data, wire_string_data, wire_none_params])
410
+ ),
411
+ )
412
+
413
+ scope, receive = _make_scope_and_receive(
414
+ "POST",
415
+ f"/threads/{thread_id}/stream/events",
416
+ {"thread_id": str(thread_id)},
417
+ body_dict={"channels": ["messages", "custom"], "since": 0},
418
+ )
419
+ req = Request(scope, receive)
420
+ res = await protocol_event_stream(req)
421
+ assert isinstance(res, StreamingResponse)
422
+
423
+ frames: list[str] = []
424
+ try:
425
+ async for chunk in res.body_iterator:
426
+ frames.append(chunk)
427
+ if len(frames) >= 2:
428
+ break
429
+ finally:
430
+ await res.body_iterator.aclose()
431
+
432
+ all_output = "".join(frames)
433
+ assert "evt-list" in all_output
434
+ assert "evt-str" in all_output
435
+
436
+