core/tools: drop process workspace/todo globals; require bound state

This commit is contained in:
2026-07-24 16:32:33 +08:00
parent 4215a8cea0
commit 1d718582ac
19 changed files with 126 additions and 235 deletions
+9 -6
View File
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
import pytest
from plyngent.tools.context import InstanceState, bind_instance
from plyngent.tools.workspace import (
clear_workspace_allowlist,
clear_workspace_root,
@@ -17,9 +18,11 @@ if TYPE_CHECKING:
@pytest.fixture
def workspace(tmp_path: Path) -> Iterator[Path]:
clear_workspace_root()
clear_workspace_allowlist()
root = set_workspace_root(tmp_path)
yield root
clear_workspace_allowlist()
clear_workspace_root()
"""Bind an InstanceState workspace for tool tests (no process globals)."""
instance = InstanceState(workspace_root=tmp_path.resolve())
with bind_instance(instance):
clear_workspace_allowlist()
root = set_workspace_root(tmp_path)
yield root
clear_workspace_allowlist()
clear_workspace_root()
+10 -5
View File
@@ -5,16 +5,21 @@ from __future__ import annotations
import inspect
from plyngent.agent import ToolTag, tool
from plyngent.tools import DEFAULT_TOOLS, default_tool_definitions, register_builtin_tools
from plyngent.tools import default_tool_definitions, register_builtin_tools
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
from plyngent.tools.chat import CHAT_TOOLS
from plyngent.tools.file import FILE_TOOLS
from plyngent.tools.process import PROCESS_TOOLS
from plyngent.tools.todo import TODO_TOOLS
from plyngent.tools.vcs import VCS_TOOLS
def test_default_tool_names_match_legacy_lists() -> None:
def test_default_tool_names_match_group_lists() -> None:
register_builtin_tools()
selected = default_tool_definitions(surface="local")
legacy = [t.name for t in DEFAULT_TOOLS]
assert sorted(t.name for t in selected) == sorted(legacy)
assert len(selected) == len(legacy)
groups = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS]
assert sorted(t.name for t in selected) == sorted(t.name for t in groups)
assert len(selected) == len(groups)
def test_all_builtins_are_async_and_tagged() -> None:
+5 -5
View File
@@ -18,10 +18,12 @@ def test_classify_copy() -> None:
def test_classify_write_overwrite_only(tmp_path: Path) -> None:
from plyngent.tools.workspace import clear_workspace_root, set_workspace_root
from plyngent.tools.context import InstanceState, bind_instance
from plyngent.tools.workspace import set_workspace_root
set_workspace_root(tmp_path)
try:
instance = InstanceState(workspace_root=tmp_path.resolve())
with bind_instance(instance):
_ = set_workspace_root(tmp_path)
# New file: no soft-confirm
assert classify_danger("write_file", {"path": "new.txt"}) is None
# Partial edits: never soft-confirm
@@ -36,8 +38,6 @@ def test_classify_write_overwrite_only(tmp_path: Path) -> None:
assert classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": False}) is None
creason = classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": True})
assert creason is not None and "overwrite" in creason
finally:
clear_workspace_root()
def test_classify_safe_tools() -> None:
+10 -19
View File
@@ -1,18 +1,15 @@
"""Todo tools via session.data PersistentDataView (no process global)."""
"""Todo tools via session.data PersistentDataView (session-bound only)."""
from __future__ import annotations
from plyngent.agent import ToolRegistry
from plyngent.agent.todo_stack import TodoStack
from plyngent.tools.context import SessionState, bind_tool_context
from plyngent.tools.todo import TODO_TOOLS, get_todo_stack, set_todo_stack
from plyngent.tools.todo import TODO_TOOLS
from plyngent.tools.view import MemoryViewStore, session_data_view
async def test_todo_tools_session_data_without_process_bind() -> None:
set_todo_stack(None)
assert get_todo_stack() is None
async def test_todo_tools_session_data() -> None:
store = MemoryViewStore({})
session = SessionState(session_id="s1", data=session_data_view(store=store))
registry = ToolRegistry(list(TODO_TOOLS), session_state=session)
@@ -36,7 +33,6 @@ async def test_todo_tools_session_data_without_process_bind() -> None:
async def test_todo_tools_prefer_session_todo_facet() -> None:
stack = TodoStack()
set_todo_stack(stack) # process bind present
store = MemoryViewStore({})
session = SessionState(session_id="s2", data=session_data_view(store=store), todo=stack)
registry = ToolRegistry(list(TODO_TOOLS), session_state=session)
@@ -48,11 +44,9 @@ async def test_todo_tools_prefer_session_todo_facet() -> None:
loaded = await store.load()
assert isinstance(loaded, dict)
assert isinstance(loaded.get("todo"), dict)
set_todo_stack(None)
async def test_todo_tools_view_isolation_two_sessions() -> None:
set_todo_stack(None)
store_a = MemoryViewStore({})
store_b = MemoryViewStore({})
session_a = SessionState(session_id="a", data=session_data_view(store=store_a))
@@ -72,13 +66,11 @@ async def test_todo_tools_view_isolation_two_sessions() -> None:
raw_b = TodoStack.from_raw(loaded_b.get("todo"))
assert [i.title for i in raw_a.all_items()] == ["only-a"]
assert [i.title for i in raw_b.all_items()] == ["only-b"]
# Live facets must not alias across sessions.
assert session_a.todo is not session_b.todo
async def test_with_bound_context_without_registry_session() -> None:
"""Handlers honor contextvars when registry does not hold session_state."""
set_todo_stack(None)
store = MemoryViewStore({})
session = SessionState(session_id="ctx", data=session_data_view(store=store))
registry = ToolRegistry(list(TODO_TOOLS), auto_bind_state=False)
@@ -90,19 +82,13 @@ async def test_with_bound_context_without_registry_session() -> None:
assert isinstance(loaded.get("todo"), dict)
async def test_session_on_todo_change_preferred_over_process() -> None:
"""Session.on_todo_change fires instead of process set_todo_stack on_change."""
set_todo_stack(None)
async def test_session_on_todo_change_fires() -> None:
hits: list[str] = []
def process_hook() -> None:
hits.append("process")
def session_hook() -> None:
hits.append("session")
stack = TodoStack()
set_todo_stack(stack, on_change=process_hook)
store = MemoryViewStore({})
session = SessionState(
session_id="hook",
@@ -113,4 +99,9 @@ async def test_session_on_todo_change_preferred_over_process() -> None:
registry = ToolRegistry(list(TODO_TOOLS), session_state=session)
_ = await registry.execute("todo_push", '{"titles": ["H"]}')
assert hits == ["session"]
set_todo_stack(None)
async def test_todo_without_session_errors() -> None:
registry = ToolRegistry(list(TODO_TOOLS))
out = await registry.execute("todo_list", "{}")
assert "error" in out.lower()
+8 -3
View File
@@ -5,7 +5,6 @@ import pytest
from plyngent.tools import (
WorkspaceError,
check_command_allowed,
clear_workspace_root,
get_workspace_root,
resolve_path,
set_command_denylist,
@@ -129,6 +128,12 @@ def test_command_denylist_policy_confirm_timeout_value(workspace: object) -> Non
def test_root_required() -> None:
clear_workspace_root()
with pytest.raises(WorkspaceError, match="not set"):
from plyngent.tools.context import InstanceState, bind_instance
with bind_instance(InstanceState()), pytest.raises(WorkspaceError, match="workspace root is not set"):
_ = get_workspace_root()
def test_unbound_instance_errors() -> None:
with pytest.raises(WorkspaceError, match="instance state is not bound"):
_ = get_workspace_root()
+8 -22
View File
@@ -1,4 +1,4 @@
"""Workspace root prefers bound InstanceState when set."""
"""Workspace policy requires bound InstanceState."""
from __future__ import annotations
@@ -16,26 +16,18 @@ from plyngent.tools.workspace import (
)
def test_get_workspace_prefers_instance(tmp_path: Path) -> None:
clear_workspace_root()
other = tmp_path / "other"
other.mkdir()
_ = set_workspace_root(tmp_path)
instance = InstanceState(workspace_root=other)
with bind_instance(instance):
assert get_workspace_root() == other.resolve()
assert get_workspace_root() == tmp_path.resolve()
clear_workspace_root()
def test_unbound_get_workspace_errors() -> None:
with pytest.raises(WorkspaceError, match="instance state is not bound"):
_ = get_workspace_root()
def test_set_workspace_mirrors_to_bound_instance(tmp_path: Path) -> None:
clear_workspace_root()
def test_set_workspace_on_bound_instance(tmp_path: Path) -> None:
instance = InstanceState()
with bind_instance(instance):
path = set_workspace_root(tmp_path)
assert instance.workspace_root == path
assert instance.workspace.root == path
assert get_workspace_root() == path
clear_workspace_root()
def test_clear_clears_bound_instance(tmp_path: Path) -> None:
@@ -44,25 +36,20 @@ def test_clear_clears_bound_instance(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
clear_workspace_root()
assert instance.workspace_root is None
with pytest.raises(WorkspaceError):
with pytest.raises(WorkspaceError, match="workspace root is not set"):
_ = get_workspace_root()
def test_resolve_path_accepts_instance_root_without_process_global(tmp_path: Path) -> None:
"""Instance-only workspace must be a valid resolve root (not only process global)."""
clear_workspace_root()
def test_resolve_path_uses_instance_root(tmp_path: Path) -> None:
target = tmp_path / "note.txt"
_ = target.write_text("hi", encoding="utf-8")
instance = InstanceState(workspace_root=tmp_path.resolve())
with bind_instance(instance):
resolved = resolve_path("note.txt")
assert resolved == target.resolve()
clear_workspace_root()
def test_path_denylist_uses_instance_policy(tmp_path: Path) -> None:
"""Path denylist is read from the bound instance policy bag."""
clear_workspace_root()
secret = tmp_path / "secrets"
secret.mkdir()
_ = (secret / "x.txt").write_text("no", encoding="utf-8")
@@ -70,4 +57,3 @@ def test_path_denylist_uses_instance_policy(tmp_path: Path) -> None:
instance.workspace.path_denylist = ("/secrets/",)
with bind_instance(instance), pytest.raises(WorkspaceError, match="denied by policy"):
_ = resolve_path("secrets/x.txt")
clear_workspace_root()