graphharbor 0.13.0.post35__tar.gz → 0.13.0.post37__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.
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/PKG-INFO +2 -2
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/pyproject.toml +2 -2
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/core_api.py +54 -17
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/protocol_api.py +40 -17
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_stream_heartbeat_resilience.py +92 -0
- graphharbor-0.13.0.post37/tests/test_subagent_history.py +213 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/.gitignore +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/LICENSE +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/README.md +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/__init__.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/__main__.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/cli.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/mcp_transport.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/server.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/store_api.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/streaming.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_application_authorization.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_cli.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_graph_discovery.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_mcp_transport.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_official_protocol_compare.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_server_paths.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/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.
|
|
3
|
+
Version: 0.13.0.post37
|
|
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.
|
|
29
|
+
Requires-Dist: graphharbor-runtime==0.13.0.post37
|
|
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.
|
|
4
|
+
version = "0.13.0.post37"
|
|
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.
|
|
53
|
+
"graphharbor-runtime==0.13.0.post37",
|
|
54
54
|
]
|
|
55
55
|
|
|
56
56
|
[project.urls]
|
|
@@ -858,10 +858,16 @@ async def threads_delete(request: Request) -> JSONResponse | Response:
|
|
|
858
858
|
return _no_content()
|
|
859
859
|
|
|
860
860
|
|
|
861
|
-
def _checkpoint_config(
|
|
861
|
+
def _checkpoint_config(
|
|
862
|
+
thread_id: UUID,
|
|
863
|
+
checkpoint_id: str | None = None,
|
|
864
|
+
checkpoint_ns: str | None = None,
|
|
865
|
+
) -> dict[str, Any]:
|
|
862
866
|
configurable: dict[str, Any] = {"thread_id": str(thread_id)}
|
|
863
867
|
if checkpoint_id:
|
|
864
868
|
configurable["checkpoint_id"] = checkpoint_id
|
|
869
|
+
if checkpoint_ns is not None:
|
|
870
|
+
configurable["checkpoint_ns"] = checkpoint_ns
|
|
865
871
|
return {"configurable": configurable}
|
|
866
872
|
|
|
867
873
|
|
|
@@ -936,57 +942,73 @@ async def threads_state(request: Request) -> JSONResponse:
|
|
|
936
942
|
if row is None or thread_id is None:
|
|
937
943
|
return _error("thread not found", 404)
|
|
938
944
|
checkpoint_id = request.path_params.get("checkpoint_id")
|
|
939
|
-
|
|
945
|
+
checkpoint_ns = request.query_params.get("checkpoint_ns")
|
|
946
|
+
if request.method == "POST":
|
|
940
947
|
try:
|
|
941
948
|
body = await request.json()
|
|
942
949
|
if isinstance(body, dict):
|
|
943
950
|
cp_data = body.get("checkpoint")
|
|
944
951
|
if isinstance(cp_data, dict):
|
|
945
|
-
|
|
946
|
-
|
|
952
|
+
if not checkpoint_id:
|
|
953
|
+
checkpoint_id = cp_data.get("checkpoint_id")
|
|
954
|
+
if not checkpoint_ns:
|
|
955
|
+
checkpoint_ns = cp_data.get("checkpoint_ns")
|
|
956
|
+
elif isinstance(cp_data, str) and not checkpoint_id:
|
|
947
957
|
checkpoint_id = cp_data
|
|
948
958
|
if not checkpoint_id:
|
|
949
959
|
checkpoint_id = body.get("checkpoint_id")
|
|
960
|
+
if not checkpoint_ns:
|
|
961
|
+
checkpoint_ns = body.get("checkpoint_ns")
|
|
950
962
|
except Exception:
|
|
951
963
|
pass
|
|
952
964
|
|
|
965
|
+
is_subgraph = bool(checkpoint_ns)
|
|
966
|
+
config = cast(
|
|
967
|
+
RunnableConfig,
|
|
968
|
+
_checkpoint_config(thread_id, checkpoint_id=checkpoint_id, checkpoint_ns=checkpoint_ns),
|
|
969
|
+
)
|
|
953
970
|
registry = getattr(request.app.state, "graph_registry", None)
|
|
954
971
|
graph_id = row.graph_id or row.metadata_.get("graph_id")
|
|
955
|
-
|
|
956
|
-
|
|
972
|
+
# Subgraphs invoked dynamically within tools do not exist in the root graph's
|
|
973
|
+
# static topology; querying the compiled graph would raise SubgraphNotFound.
|
|
974
|
+
# Route subgraphs directly to the persistent checkpointer.
|
|
975
|
+
if not is_subgraph and registry is not None and graph_id and str(graph_id) in registry.ids():
|
|
957
976
|
try:
|
|
958
977
|
async with registry.open(str(graph_id), config) as graph:
|
|
959
978
|
return JSONResponse(_state_from_snapshot(await graph.aget_state(config)))
|
|
960
979
|
except Exception as exc:
|
|
961
980
|
return _error(f"checkpoint read failed: {exc}", 503)
|
|
962
981
|
try:
|
|
963
|
-
item = await get_checkpointer().aget_tuple(
|
|
964
|
-
cast(RunnableConfig, _checkpoint_config(thread_id, checkpoint_id))
|
|
965
|
-
)
|
|
982
|
+
item = await get_checkpointer().aget_tuple(config)
|
|
966
983
|
except Exception as exc:
|
|
967
984
|
return _error(f"checkpoint read failed: {exc}", 503)
|
|
968
985
|
if item is None:
|
|
969
986
|
return JSONResponse(
|
|
970
987
|
_plain(
|
|
971
988
|
{
|
|
972
|
-
"values": row.values_ or {},
|
|
989
|
+
"values": {} if is_subgraph else (row.values_ or {}),
|
|
973
990
|
"next": [],
|
|
974
|
-
"checkpoint": _checkpoint_key(
|
|
991
|
+
"checkpoint": _checkpoint_key(config),
|
|
975
992
|
"metadata": {},
|
|
976
993
|
"created_at": None,
|
|
977
994
|
"parent_checkpoint": None,
|
|
978
995
|
"tasks": [],
|
|
979
|
-
"interrupts": _stored_interrupts(row),
|
|
996
|
+
"interrupts": [] if is_subgraph else _stored_interrupts(row),
|
|
980
997
|
}
|
|
981
998
|
)
|
|
982
999
|
)
|
|
983
1000
|
state = _state_from_tuple(item)
|
|
984
|
-
if not state.get("interrupts"):
|
|
1001
|
+
if not is_subgraph and not state.get("interrupts"):
|
|
985
1002
|
state["interrupts"] = _stored_interrupts(row)
|
|
986
1003
|
# Some LangChain/Deep Agents checkpoints keep only scheduler bookkeeping in
|
|
987
1004
|
# channel_values while the durable thread row has the final projected state.
|
|
988
1005
|
# Expose that state instead of returning the misleading bare __pregel_tasks.
|
|
989
|
-
if
|
|
1006
|
+
if (
|
|
1007
|
+
not is_subgraph
|
|
1008
|
+
and checkpoint_id is None
|
|
1009
|
+
and not _has_projected_values(state.get("values"))
|
|
1010
|
+
and row.values_
|
|
1011
|
+
):
|
|
990
1012
|
state["values"] = _plain(row.values_)
|
|
991
1013
|
return JSONResponse(state)
|
|
992
1014
|
|
|
@@ -1002,7 +1024,18 @@ async def threads_history(request: Request) -> JSONResponse:
|
|
|
1002
1024
|
limit, _ = _pagination(request, payload)
|
|
1003
1025
|
except ValueError as exc:
|
|
1004
1026
|
return _error(str(exc))
|
|
1005
|
-
|
|
1027
|
+
|
|
1028
|
+
checkpoint_ns: str | None = None
|
|
1029
|
+
if request.method == "POST":
|
|
1030
|
+
cp_data = payload.get("checkpoint")
|
|
1031
|
+
if isinstance(cp_data, dict):
|
|
1032
|
+
checkpoint_ns = cp_data.get("checkpoint_ns")
|
|
1033
|
+
if not checkpoint_ns:
|
|
1034
|
+
checkpoint_ns = payload.get("checkpoint_ns")
|
|
1035
|
+
if not checkpoint_ns:
|
|
1036
|
+
checkpoint_ns = request.query_params.get("checkpoint_ns")
|
|
1037
|
+
|
|
1038
|
+
config = cast(RunnableConfig, _checkpoint_config(thread_id, checkpoint_ns=checkpoint_ns))
|
|
1006
1039
|
before = (
|
|
1007
1040
|
payload.get("before") if request.method == "POST" else request.query_params.get("before")
|
|
1008
1041
|
)
|
|
@@ -1011,12 +1044,16 @@ async def threads_history(request: Request) -> JSONResponse:
|
|
|
1011
1044
|
before = json.loads(before)
|
|
1012
1045
|
except (TypeError, json.JSONDecodeError):
|
|
1013
1046
|
return _error("before must be a JSON object")
|
|
1014
|
-
if isinstance(before,
|
|
1047
|
+
if isinstance(before, str):
|
|
1048
|
+
before = {"configurable": {"checkpoint_id": before}}
|
|
1049
|
+
elif isinstance(before, dict):
|
|
1015
1050
|
before = {"configurable": before}
|
|
1016
1051
|
before_config = cast(RunnableConfig | None, before)
|
|
1052
|
+
|
|
1053
|
+
is_subgraph = bool(checkpoint_ns)
|
|
1017
1054
|
registry = getattr(request.app.state, "graph_registry", None)
|
|
1018
1055
|
graph_id = row.graph_id or row.metadata_.get("graph_id")
|
|
1019
|
-
if registry is not None and graph_id and str(graph_id) in registry.ids():
|
|
1056
|
+
if not is_subgraph and registry is not None and graph_id and str(graph_id) in registry.ids():
|
|
1020
1057
|
try:
|
|
1021
1058
|
async with registry.open(str(graph_id), config) as graph:
|
|
1022
1059
|
return JSONResponse(
|
|
@@ -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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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:
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
341
|
-
or
|
|
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(
|
|
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 (
|
|
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)
|
{graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_stream_heartbeat_resilience.py
RENAMED
|
@@ -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
|
+
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from typing import Annotated, TypedDict
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from langgraph.channels import DeltaChannel
|
|
11
|
+
from langgraph.checkpoint.base import empty_checkpoint
|
|
12
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
13
|
+
from langgraph.graph import END, START, StateGraph
|
|
14
|
+
from starlette.requests import Request
|
|
15
|
+
|
|
16
|
+
from langhost import core_api
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RootState(TypedDict):
|
|
20
|
+
messages: Annotated[
|
|
21
|
+
list[str],
|
|
22
|
+
DeltaChannel(lambda state, writes: state + [item for batch in writes for item in batch]),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.asyncio
|
|
27
|
+
async def test_subagent_namespace_state_and_history(monkeypatch):
|
|
28
|
+
tid = uuid4()
|
|
29
|
+
root_config = {"configurable": {"thread_id": str(tid), "checkpoint_ns": ""}}
|
|
30
|
+
subagent_ns = "tools:call_subagent_abc123"
|
|
31
|
+
|
|
32
|
+
saver = InMemorySaver()
|
|
33
|
+
|
|
34
|
+
# 1. 模拟根图执行,保存在根命名空间
|
|
35
|
+
root_graph = (
|
|
36
|
+
StateGraph(RootState)
|
|
37
|
+
.add_node("agent", lambda state: {"messages": ["call task tool"]})
|
|
38
|
+
.add_edge(START, "agent")
|
|
39
|
+
.add_edge("agent", END)
|
|
40
|
+
.compile(checkpointer=saver)
|
|
41
|
+
)
|
|
42
|
+
await root_graph.ainvoke({"messages": ["user prompt"]}, root_config)
|
|
43
|
+
|
|
44
|
+
# 2. 模拟 Subagent 在独立命名空间下的执行历史(写入多个 step checkpoint)
|
|
45
|
+
# Step 1: Subagent 接收指令并发出 read_file 工具调用
|
|
46
|
+
cp_id_1 = "00000000-0000-0000-0000-000000000001"
|
|
47
|
+
config_step1 = {
|
|
48
|
+
"configurable": {
|
|
49
|
+
"thread_id": str(tid),
|
|
50
|
+
"checkpoint_ns": subagent_ns,
|
|
51
|
+
"checkpoint_id": cp_id_1,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
cp1 = empty_checkpoint()
|
|
55
|
+
cp1["id"] = cp_id_1
|
|
56
|
+
cp1["channel_values"] = {
|
|
57
|
+
"messages": [
|
|
58
|
+
{"role": "human", "content": "investigate code"},
|
|
59
|
+
{"role": "ai", "tool_calls": [{"name": "read_file", "args": {"path": "main.py"}}]},
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
cp1["channel_versions"] = {"messages": 1}
|
|
63
|
+
await saver.aput(config_step1, cp1, {"step": 1}, {"messages": 1})
|
|
64
|
+
|
|
65
|
+
# Step 2: Subagent 获得 read_file 返回并给出最终结论
|
|
66
|
+
cp_id_2 = "00000000-0000-0000-0000-000000000002"
|
|
67
|
+
config_step2 = {
|
|
68
|
+
"configurable": {
|
|
69
|
+
"thread_id": str(tid),
|
|
70
|
+
"checkpoint_ns": subagent_ns,
|
|
71
|
+
"checkpoint_id": cp_id_2,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
cp2 = empty_checkpoint()
|
|
75
|
+
cp2["id"] = cp_id_2
|
|
76
|
+
cp2["channel_values"] = {
|
|
77
|
+
"messages": [
|
|
78
|
+
{"role": "human", "content": "investigate code"},
|
|
79
|
+
{"role": "ai", "tool_calls": [{"name": "read_file", "args": {"path": "main.py"}}]},
|
|
80
|
+
{"role": "tool", "name": "read_file", "content": "def main(): pass"},
|
|
81
|
+
{"role": "ai", "content": "investigation finished"},
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
cp2["channel_versions"] = {"messages": 2}
|
|
85
|
+
await saver.aput(config_step2, cp2, {"step": 2}, {"messages": 2})
|
|
86
|
+
|
|
87
|
+
# 3. 准备 mock 的 graph_registry 与 get_checkpointer
|
|
88
|
+
@asynccontextmanager
|
|
89
|
+
async def opened(graph_id, run_config):
|
|
90
|
+
# 静态注册中只有 root_graph,子图在 registry 中并不存在
|
|
91
|
+
assert graph_id == "root_agent"
|
|
92
|
+
yield root_graph
|
|
93
|
+
|
|
94
|
+
async def get_thread(request):
|
|
95
|
+
return (
|
|
96
|
+
SimpleNamespace(
|
|
97
|
+
graph_id="root_agent",
|
|
98
|
+
metadata_={},
|
|
99
|
+
values_={"messages": ["root cached messages"]},
|
|
100
|
+
interrupts={},
|
|
101
|
+
),
|
|
102
|
+
None,
|
|
103
|
+
tid,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(core_api, "_get_thread", get_thread)
|
|
107
|
+
monkeypatch.setattr(core_api, "get_checkpointer", lambda: saver)
|
|
108
|
+
|
|
109
|
+
app = SimpleNamespace(
|
|
110
|
+
state=SimpleNamespace(
|
|
111
|
+
graph_registry=SimpleNamespace(open=opened, ids=lambda: ("root_agent",))
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# 4. 场景 1:根图默认查询(回归测试)
|
|
116
|
+
req_root = Request(
|
|
117
|
+
{
|
|
118
|
+
"type": "http",
|
|
119
|
+
"method": "GET",
|
|
120
|
+
"app": app,
|
|
121
|
+
"path_params": {},
|
|
122
|
+
"query_string": b"",
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
root_state = json.loads((await core_api.threads_state(req_root)).body)
|
|
126
|
+
assert root_state["checkpoint"]["checkpoint_ns"] == ""
|
|
127
|
+
assert root_state["values"]["messages"] == ["user prompt", "call task tool"]
|
|
128
|
+
|
|
129
|
+
# 5. 场景 2:通过 POST /state/checkpoint 指定子图 checkpoint_ns 查询最新状态
|
|
130
|
+
req_sub_post = Request(
|
|
131
|
+
{
|
|
132
|
+
"type": "http",
|
|
133
|
+
"method": "POST",
|
|
134
|
+
"app": app,
|
|
135
|
+
"path_params": {},
|
|
136
|
+
"query_string": b"",
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
req_sub_post._json = {"checkpoint": {"checkpoint_ns": subagent_ns}}
|
|
140
|
+
sub_state_post = json.loads((await core_api.threads_state(req_sub_post)).body)
|
|
141
|
+
assert sub_state_post["checkpoint"]["checkpoint_ns"] == subagent_ns
|
|
142
|
+
assert sub_state_post["checkpoint"]["checkpoint_id"] == cp_id_2
|
|
143
|
+
assert len(sub_state_post["values"]["messages"]) == 4
|
|
144
|
+
assert sub_state_post["values"]["messages"][1]["tool_calls"][0]["name"] == "read_file"
|
|
145
|
+
assert sub_state_post["values"]["messages"][3]["content"] == "investigation finished"
|
|
146
|
+
|
|
147
|
+
# 6. 场景 3:通过 GET /state?checkpoint_ns=... 查询最新状态
|
|
148
|
+
req_sub_get = Request(
|
|
149
|
+
{
|
|
150
|
+
"type": "http",
|
|
151
|
+
"method": "GET",
|
|
152
|
+
"app": app,
|
|
153
|
+
"path_params": {},
|
|
154
|
+
"query_string": f"checkpoint_ns={subagent_ns}".encode(),
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
sub_state_get = json.loads((await core_api.threads_state(req_sub_get)).body)
|
|
158
|
+
assert sub_state_get["checkpoint"]["checkpoint_ns"] == subagent_ns
|
|
159
|
+
assert sub_state_get["values"] == sub_state_post["values"]
|
|
160
|
+
|
|
161
|
+
# 7. 场景 4:指定特定 checkpoint_id 获取子图历史某一步状态
|
|
162
|
+
req_sub_step1 = Request(
|
|
163
|
+
{
|
|
164
|
+
"type": "http",
|
|
165
|
+
"method": "POST",
|
|
166
|
+
"app": app,
|
|
167
|
+
"path_params": {},
|
|
168
|
+
"query_string": b"",
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
req_sub_step1._json = {
|
|
172
|
+
"checkpoint": {
|
|
173
|
+
"checkpoint_ns": subagent_ns,
|
|
174
|
+
"checkpoint_id": cp_id_1,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
sub_step1_state = json.loads((await core_api.threads_state(req_sub_step1)).body)
|
|
178
|
+
assert sub_step1_state["checkpoint"]["checkpoint_id"] == cp_id_1
|
|
179
|
+
assert len(sub_step1_state["values"]["messages"]) == 2
|
|
180
|
+
|
|
181
|
+
# 8. 场景 5:通过 POST /history 指定子图 checkpoint_ns 查询历史列表
|
|
182
|
+
req_hist_post = Request(
|
|
183
|
+
{
|
|
184
|
+
"type": "http",
|
|
185
|
+
"method": "POST",
|
|
186
|
+
"app": app,
|
|
187
|
+
"path_params": {},
|
|
188
|
+
"query_string": b"",
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
req_hist_post._json = {"checkpoint": {"checkpoint_ns": subagent_ns}, "limit": 10}
|
|
192
|
+
sub_hist = json.loads((await core_api.threads_history(req_hist_post)).body)
|
|
193
|
+
assert len(sub_hist) == 2
|
|
194
|
+
# 历史列表按倒序排列(最新在前)
|
|
195
|
+
assert sub_hist[0]["checkpoint"]["checkpoint_id"] == cp_id_2
|
|
196
|
+
assert sub_hist[1]["checkpoint"]["checkpoint_id"] == cp_id_1
|
|
197
|
+
for item in sub_hist:
|
|
198
|
+
assert item["checkpoint"]["checkpoint_ns"] == subagent_ns
|
|
199
|
+
|
|
200
|
+
# 9. 场景 6:查询不存在的子图命名空间,返回空 values,不被根图数据污染
|
|
201
|
+
req_nonexistent = Request(
|
|
202
|
+
{
|
|
203
|
+
"type": "http",
|
|
204
|
+
"method": "GET",
|
|
205
|
+
"app": app,
|
|
206
|
+
"path_params": {},
|
|
207
|
+
"query_string": b"checkpoint_ns=tools:nonexistent_call",
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
nonexistent_state = json.loads((await core_api.threads_state(req_nonexistent)).body)
|
|
211
|
+
assert nonexistent_state["checkpoint"]["checkpoint_ns"] == "tools:nonexistent_call"
|
|
212
|
+
assert nonexistent_state["values"] == {}
|
|
213
|
+
assert nonexistent_state["interrupts"] == []
|
|
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
|
{graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_application_authorization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_official_protocol_compare.py
RENAMED
|
File without changes
|
|
File without changes
|
{graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_thread_state_projection.py
RENAMED
|
File without changes
|