test_workspace_backend.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. """文件后端在"缺 session 身份的 Runtime"下必须能自救。
  2. 背景(真实故障):摘要压缩中间件在 `awrap_model_call` 里需要后端,它按框架自己的写法
  3. 构造一个 runtime:
  4. ```python
  5. config = cast("RunnableConfig", getattr(runtime, "config", {}))
  6. tool_runtime = ToolRuntime(..., config=config, ...)
  7. ```
  8. 而 `ModelRequest.runtime` 的类型是 `langgraph.runtime.Runtime`,**它没有 `config` 字段**,
  9. 于是 config 恒为 `{}`。若工厂只在"属性不存在"时回退运行上下文,这里就会直接抛
  10. `ValueError: 文件后端缺少服务端会话身份`——表现为长会话(或读了长文档的子智能体)
  11. 一触发压缩就整轮失败,而不是压缩生效。
  12. """
  13. from __future__ import annotations
  14. from typing import Any
  15. import pytest
  16. from agents import workspace
  17. from agents.workspace import backend_for_runtime, session_root
  18. @pytest.fixture(autouse=True)
  19. def isolate_root(tmp_path, monkeypatch):
  20. """把沙箱根挪到临时目录,避免测试往仓库 data/workspaces 写文件。"""
  21. monkeypatch.setattr(workspace, "_SANDBOX_BASE", tmp_path, raising=False)
  22. monkeypatch.setattr(workspace.config, "ROOT", tmp_path, raising=False)
  23. return tmp_path
  24. class _LanggraphRuntime:
  25. """最小复刻 langgraph.runtime.Runtime:**故意没有 config 属性**。"""
  26. def __init__(self) -> None:
  27. self.context = None
  28. self.store = None
  29. self.stream_writer = lambda _: None
  30. def _call_factory_with_empty_config(seen: dict[str, Any]) -> None:
  31. """在 langgraph 运行上下文里,用框架那种 config={} 的 ToolRuntime 调工厂。"""
  32. from langchain.tools import ToolRuntime
  33. runtime = ToolRuntime(
  34. state={}, context=None, stream_writer=lambda _: None, store=None,
  35. config={}, tool_call_id=None, tools=[],
  36. )
  37. backend = backend_for_runtime(runtime)
  38. seen["routes"] = sorted(backend.routes)
  39. seen["has_default"] = backend.default is not None
  40. def test_backend_falls_back_to_run_context_when_runtime_config_is_empty():
  41. """config 为空字典时必须回退到运行上下文里的会话身份,而不是直接失败。"""
  42. from langgraph.graph import END, START, StateGraph
  43. seen: dict[str, Any] = {}
  44. def node(_state):
  45. _call_factory_with_empty_config(seen)
  46. return {}
  47. graph = StateGraph(dict)
  48. graph.add_node("n", node)
  49. graph.add_edge(START, "n")
  50. graph.add_edge("n", END)
  51. graph.compile().invoke(
  52. {}, {"configurable": {"workspace_user": "alice", "workspace_session": "s-1"}})
  53. assert "/skills/" in seen["routes"] and seen["has_default"]
  54. # 落到了正确的会话沙箱(而不是别处或临时目录)
  55. assert (session_root("alice", "s-1") / "workspace").is_dir()
  56. def test_backend_still_fails_loudly_without_any_session_identity():
  57. """两个来源都没有身份时仍要失败——绝不能悄悄写到别的会话目录。"""
  58. from langgraph.graph import END, START, StateGraph
  59. def node(_state):
  60. backend_for_runtime(_LanggraphRuntime())
  61. return {}
  62. graph = StateGraph(dict)
  63. graph.add_node("n", node)
  64. graph.add_edge(START, "n")
  65. graph.add_edge("n", END)
  66. with pytest.raises(ValueError, match="缺少服务端会话身份"):
  67. graph.compile().invoke({}, {"configurable": {"unrelated": "x"}})
  68. def test_backend_accepts_runtime_whose_config_carries_identity():
  69. """文件工具的路径(ToolRuntime 带完整 config)照旧工作。"""
  70. from langchain.tools import ToolRuntime
  71. runtime = ToolRuntime(
  72. state={}, context=None, stream_writer=lambda _: None, store=None, tools=[],
  73. config={"configurable": {"workspace_user": "bob", "workspace_session": "s-2"}},
  74. tool_call_id="call-1",
  75. )
  76. backend = backend_for_runtime(runtime)
  77. assert backend.default is not None
  78. assert (session_root("bob", "s-2") / "workspace").is_dir()
  79. def test_backend_prefers_runtime_config_over_run_context():
  80. """两者都有身份时以 runtime.config 为准(显式传入的更权威)。"""
  81. from langchain.tools import ToolRuntime
  82. runtime = ToolRuntime(
  83. state={}, context=None, stream_writer=lambda _: None, store=None, tools=[],
  84. config={"configurable": {"workspace_user": "from-config", "workspace_session": "s"}},
  85. tool_call_id="call-1",
  86. )
  87. from langgraph.graph import END, START, StateGraph
  88. def node(_state):
  89. backend_for_runtime(runtime)
  90. return {}
  91. graph = StateGraph(dict)
  92. graph.add_node("n", node)
  93. graph.add_edge(START, "n")
  94. graph.add_edge("n", END)
  95. graph.compile().invoke(
  96. {}, {"configurable": {"workspace_user": "from-context", "workspace_session": "s"}})
  97. assert (session_root("from-config", "s") / "workspace").is_dir()