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
+13 -19
View File
@@ -26,7 +26,6 @@ from plyngent.config.models import DatabaseConfig
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
from plyngent.prompting import NonInteractiveBackend, configure_prompting from plyngent.prompting import NonInteractiveBackend, configure_prompting
from plyngent.runtime import ProviderNotSupportedError, create_client from plyngent.runtime import ProviderNotSupportedError, create_client
from plyngent.tools import set_workspace_root
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping from collections.abc import Mapping
@@ -115,16 +114,8 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
return text or None return text or None
def _setup_workspace_and_hooks( def _setup_hooks(*, interactive: bool) -> None:
store: ConfigStore, """Install interactive limit hooks (workspace policy is set on ReplState)."""
workspace: Path,
*,
interactive: bool,
) -> None:
_ = set_workspace_root(workspace)
from plyngent.tools import set_path_denylist
set_path_denylist(store.agent_config.path_denylist or None)
if interactive: if interactive:
install_cli_limit_hooks() install_cli_limit_hooks()
else: else:
@@ -217,7 +208,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
if store.recoverable_providers: if store.recoverable_providers:
_warn_recoverable_providers(store.recoverable_providers) _warn_recoverable_providers(store.recoverable_providers)
_setup_workspace_and_hooks(store, workspace, interactive=interactive) _setup_hooks(interactive=interactive)
# --yes forces sticky YOLO; else derive from config.confirm_destructive. # --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode from plyngent.cli.state import YoloMode
@@ -297,6 +288,12 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
interactive_limits=interactive, interactive_limits=interactive,
yolo=yolo, yolo=yolo,
) )
# Path denylist and policy confirm live on instance.workspace (no process bag).
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
if interactive:
from plyngent.cli.limits import prompt_policy_command_confirm
state.instance_state.workspace.policy_confirm_hook = prompt_policy_command_confirm
# Seed cache if we already fetched; else warm in background for Tab. # Seed cache if we already fetched; else warm in background for Tab.
if remote_ids is not None: if remote_ids is not None:
state.seed_remote_models(remote_ids) state.seed_remote_models(remote_ids)
@@ -323,20 +320,17 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
return EXIT_OK return EXIT_OK
finally: finally:
await memory.close() await memory.close()
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
set_policy_confirm_hook(None)
clear_policy_allowed_commands()
# PTY + temp workspace cleanup via instance shutdown when state exists. # PTY + temp workspace cleanup via instance shutdown when state exists.
state_obj = locals().get("state") state_obj = locals().get("state")
if isinstance(state_obj, ReplState): if isinstance(state_obj, ReplState):
state_obj.instance_state.workspace.policy_confirm_hook = None
state_obj.instance_state.workspace.policy_allowed_commands.clear()
await state_obj.instance_state.shutdown() await state_obj.instance_state.shutdown()
else: else:
# No ReplState: only PTY class cleanup (temps require instance allowlist).
from plyngent.tools.process.pty_session import PtyManager from plyngent.tools.process.pty_session import PtyManager
from plyngent.tools.temp_workspace import cleanup_temporary_workspaces
PtyManager.close_all() PtyManager.close_all()
_ = cleanup_temporary_workspaces()
def _configure_logging(level: str) -> None: def _configure_logging(level: str) -> None:
@@ -384,7 +378,7 @@ def main(ctx: click.Context, log_level: str) -> None:
) )
@click.option("--provider", "provider_name", default=None, help="Provider name from config.") @click.option("--provider", "provider_name", default=None, help="Provider name from config.")
@click.option("--model", default=None, help="Model id.") @click.option("--model", default=None, help="Model id.")
@click.option("--tools/--no-tools", default=True, show_default=True, help="Enable DEFAULT_TOOLS.") @click.option("--tools/--no-tools", default=True, show_default=True, help="Enable catalog tools (local surface).")
@click.option( @click.option(
"--workspace", "--workspace",
type=click.Path(path_type=Path, file_okay=False, exists=True), type=click.Path(path_type=Path, file_okay=False, exists=True),
+5 -4
View File
@@ -350,9 +350,10 @@ async def prompt_workspace_mismatch_async(
def install_cli_limit_hooks() -> None: def install_cli_limit_hooks() -> None:
"""Register interactive continue hooks, policy confirm, and prompt cancel-pause.""" """Register interactive continue hooks and prompt cancel-pause.
Command denylist policy confirm is set on :class:`~plyngent.tools.context.InstanceState`
when the REPL starts (no process-global hook).
"""
configure_prompting(pause_factory=pause_task_cancel_for_prompt) configure_prompting(pause_factory=pause_task_cancel_for_prompt)
PtyManager.set_limit_continue_hook(prompt_continue_limit) PtyManager.set_limit_continue_hook(prompt_continue_limit)
from plyngent.tools.workspace import set_policy_confirm_hook
set_policy_confirm_hook(prompt_policy_command_confirm)
+4 -13
View File
@@ -18,11 +18,7 @@ from plyngent.cli.models_source import (
) )
from plyngent.memory.database.store import normalize_workspace from plyngent.memory.database.store import normalize_workspace
from plyngent.runtime import create_client from plyngent.runtime import create_client
from plyngent.tools import ( from plyngent.tools import InstanceState, SessionState
InstanceState,
SessionState,
set_workspace_root,
)
from plyngent.tools.view import MemoryViewStore, session_data_view from plyngent.tools.view import MemoryViewStore, session_data_view
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -80,8 +76,6 @@ class ReplState:
self.workspace = Path(self.workspace).expanduser().resolve() self.workspace = Path(self.workspace).expanduser().resolve()
self.instance_state.workspace_root = self.workspace self.instance_state.workspace_root = self.workspace
self.instance_state.workspace.root = self.workspace self.instance_state.workspace.root = self.workspace
# Process-global workspace remains for tools/tests that run without instance bind.
_ = set_workspace_root(self.workspace)
self.session_state = self._session_data_for_todo() self.session_state = self._session_data_for_todo()
self.agent = self._make_agent() self.agent = self._make_agent()
self.sync_display_flags() self.sync_display_flags()
@@ -165,11 +159,10 @@ class ReplState:
) )
def _bind_todo_tools(self) -> None: def _bind_todo_tools(self) -> None:
"""Bind session/instance state for tools (prefer context over process globals). """Bind session/instance state for tools.
Seeds ``session.data["todo"]`` from the live stack and attaches Seeds ``session.data["todo"]`` from the live stack and attaches
:meth:`_todo_on_change` so memory persist no longer depends on :meth:`_todo_on_change` for memory persist.
process-global ``set_todo_stack(on_change=...)``.
""" """
self.session_state.session_id = self.session_id self.session_state.session_id = self.session_id
self.session_state.todo = self.todo_stack self.session_state.todo = self.todo_stack
@@ -397,10 +390,9 @@ class ReplState:
from plyngent.cli.selection import select_model, select_provider from plyngent.cli.selection import select_model, select_provider
from plyngent.runtime import ProviderNotSupportedError from plyngent.runtime import ProviderNotSupportedError
from plyngent.tools import set_path_denylist
self.config.reload() self.config.reload()
set_path_denylist(self.config.agent_config.path_denylist or None) self.instance_state.workspace.path_denylist = tuple(self.config.agent_config.path_denylist or ())
selectable = self.config.selectable_providers() selectable = self.config.selectable_providers()
preferred_provider = self.provider_name if self.provider_name in selectable else None preferred_provider = self.provider_name if self.provider_name in selectable else None
@@ -438,7 +430,6 @@ class ReplState:
self.workspace = resolved self.workspace = resolved
self.instance_state.workspace_root = resolved self.instance_state.workspace_root = resolved
self.instance_state.workspace.root = resolved self.instance_state.workspace.root = resolved
_ = set_workspace_root(resolved)
if hasattr(self, "agent") and self.agent.tools is not None: if hasattr(self, "agent") and self.agent.tools is not None:
self.agent.tools.set_instance_state(self.instance_state) self.agent.tools.set_instance_state(self.instance_state)
-5
View File
@@ -41,8 +41,6 @@ from .process import write_pty_keys as write_pty_keys
from .temp_workspace import cleanup_temporary_workspaces as cleanup_temporary_workspaces from .temp_workspace import cleanup_temporary_workspaces as cleanup_temporary_workspaces
from .temp_workspace import new_temporary_workspace as new_temporary_workspace from .temp_workspace import new_temporary_workspace as new_temporary_workspace
from .todo import TODO_TOOLS as TODO_TOOLS from .todo import TODO_TOOLS as TODO_TOOLS
from .todo import get_todo_stack as get_todo_stack
from .todo import set_todo_stack as set_todo_stack
from .todo import todo_clear as todo_clear from .todo import todo_clear as todo_clear
from .todo import todo_list as todo_list from .todo import todo_list as todo_list
from .todo import todo_pop as todo_pop from .todo import todo_pop as todo_pop
@@ -82,6 +80,3 @@ from .workspace import set_path_denylist as set_path_denylist
from .workspace import set_policy_confirm_hook as set_policy_confirm_hook from .workspace import set_policy_confirm_hook as set_policy_confirm_hook
from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout from .workspace import set_policy_confirm_timeout as set_policy_confirm_timeout
from .workspace import set_workspace_root as set_workspace_root from .workspace import set_workspace_root as set_workspace_root
# Deprecated alias: prefer default_tool_definitions() (catalog select).
DEFAULT_TOOLS = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS]
+6 -2
View File
@@ -211,9 +211,13 @@ def _ensure_builtin_definitions_registered(catalog: ToolCatalog) -> None:
import time (usually the process catalog). Test ``catalog_scope(empty=True)`` import time (usually the process catalog). Test ``catalog_scope(empty=True)``
overrides must still see builtins for ``default_tool_definitions``. overrides must still see builtins for ``default_tool_definitions``.
""" """
from plyngent.tools import DEFAULT_TOOLS 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
for definition in DEFAULT_TOOLS: for definition in (*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS):
if catalog.get(definition.name) is None: if catalog.get(definition.name) is None:
catalog.register(definition, source=_BUILTIN_SOURCE) catalog.register(definition, source=_BUILTIN_SOURCE)
+2 -3
View File
@@ -70,10 +70,9 @@ class SessionState:
session_id: int | str | None = None session_id: int | str | None = None
data: PersistentDataView[Any] = field(default_factory=_default_session_data) data: PersistentDataView[Any] = field(default_factory=_default_session_data)
# Live domain object during migration (also under data["todo"] when views bind). # Live domain object (also under data["todo"] when views bind).
todo: TodoStack | None = None todo: TodoStack | None = None
# Host hook after todo tools mutate (e.g. CLI memory persist). Prefer this over # Host hook after todo tools mutate (e.g. CLI memory persist).
# process-global set_todo_stack(on_change=...) when a session is bound.
on_todo_change: Callable[[], None] | None = None on_todo_change: Callable[[], None] | None = None
# Soft-confirm grants live map: tool_name → True (Phase 1 key is tool name). # Soft-confirm grants live map: tool_name → True (Phase 1 key is tool name).
# Durable copy lives under data["grants"] (see plyngent.tools.grants). # Durable copy lives under data["grants"] (see plyngent.tools.grants).
+5 -1
View File
@@ -47,8 +47,12 @@ def cleanup_temporary_workspaces() -> int:
"""Remove directories created by :func:`new_temporary_workspace` (chat exit). """Remove directories created by :func:`new_temporary_workspace` (chat exit).
Only deletes paths still under the system temp dir. Returns the number of Only deletes paths still under the system temp dir. Returns the number of
directories removed. directories removed. No-op when no instance is bound.
""" """
from plyngent.tools.context import get_instance
if get_instance() is None:
return 0
removed = 0 removed = 0
for path in pop_owned_temporary_workspaces(): for path in pop_owned_temporary_workspaces():
if not _is_under_system_temp(path): if not _is_under_system_temp(path):
+4 -47
View File
@@ -9,84 +9,41 @@ if TYPE_CHECKING:
from plyngent.agent.todo_stack import TodoStack, TodoStatus from plyngent.agent.todo_stack import TodoStack, TodoStatus
_stack: TodoStack | None = None
_on_change: Callable[[], None] | None = None
_VALID_STATUS = frozenset({"pending", "in_progress", "done", "cancelled"}) _VALID_STATUS = frozenset({"pending", "in_progress", "done", "cancelled"})
def set_todo_stack(stack: TodoStack | None, *, on_change: Callable[[], None] | None = None) -> None:
"""Bind the session todo stack for tool handlers (and optional persist hook).
Process-global bind remains for CLI/agent hosts that keep a live
:class:`~plyngent.agent.todo_stack.TodoStack` outside the view store.
Prefer session ``data["todo"]`` transactions when available.
"""
global _stack, _on_change # noqa: PLW0603 — intentional process bind
_stack = stack
_on_change = on_change
def get_todo_stack() -> TodoStack | None:
return _stack
def _notify() -> None: def _notify() -> None:
"""Fire host persist hooks: prefer session.on_todo_change, else process bind.""" """Fire host persist hook on the bound session (if any)."""
from plyngent.tools.context import get_session from plyngent.tools.context import get_session
session = get_session() session = get_session()
if session is not None and session.on_todo_change is not None: if session is not None and session.on_todo_change is not None:
session.on_todo_change() session.on_todo_change()
return
if _on_change is not None:
_on_change()
def _process_stack() -> TodoStack:
if _stack is None:
msg = "todo stack is not available in this session"
raise RuntimeError(msg)
return _stack
async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str: async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str:
"""Run *mutator* against the session todo stack and publish changes. """Run *mutator* against the session todo stack and publish changes.
Prefer:: Requires a bound :class:`~plyngent.tools.context.SessionState`::
async with session.data: async with session.data:
stack = session.data["todo"].typed(TodoStack) stack = session.data["todo"].typed(TodoStack)
... ...
When a host already bound ``session.todo`` / process ``set_todo_stack``,
mutate that live object and still refresh the view buffer when a session
is bound so commits stay consistent.
""" """
from plyngent.agent.todo_stack import TodoStack from plyngent.agent.todo_stack import TodoStack
from plyngent.tools.context import get_session from plyngent.tools.context import require_session
session = get_session()
if session is None:
result = mutator(_process_stack())
_notify()
return result
session = require_session()
result = "" result = ""
async with session.data as data: async with session.data as data:
# Prefer host-bound live stack (CLI keeps TodoStack for nags / memory).
if session.todo is not None: if session.todo is not None:
stack = session.todo stack = session.todo
elif _stack is not None:
stack = _stack
session.todo = stack
else: else:
stack = data["todo"].typed(TodoStack) stack = data["todo"].typed(TodoStack)
session.todo = stack session.todo = stack
# Keep view domain + buffer in sync for durable commit. # Keep view domain + buffer in sync for durable commit.
data["todo"].store(stack) data["todo"].store(stack)
result = mutator(stack) result = mutator(stack)
# View commit serialized to_raw; host on_todo_change may persist CLI memory.
_notify() _notify()
return result return result
+37 -68
View File
@@ -51,9 +51,8 @@ class WorkspaceError(ValueError):
class WorkspacePolicy: class WorkspacePolicy:
"""Workspace path/command policy for one agent host / instance. """Workspace path/command policy for one agent host / instance.
Prefer hanging this on :class:`~plyngent.tools.context.InstanceState.workspace`. Lives on :class:`~plyngent.tools.context.InstanceState.workspace`. There is
Module-level APIs still fall back to a process-global policy when no instance no process-global policy bag — hosts must bind instance state around tool use.
is bound (tests and early boot).
""" """
root: Path | None = None root: Path | None = None
@@ -66,79 +65,54 @@ class WorkspacePolicy:
policy_confirm_timeout_seconds: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS policy_confirm_timeout_seconds: float = DEFAULT_POLICY_CONFIRM_TIMEOUT_SECONDS
# Process fallback when no InstanceState is bound.
_process_policy = WorkspacePolicy()
def _bound_instance() -> InstanceState | None: def _bound_instance() -> InstanceState | None:
from plyngent.tools.context import get_instance from plyngent.tools.context import get_instance
return get_instance() return get_instance()
def active_workspace_policy() -> WorkspacePolicy: def require_bound_instance() -> InstanceState:
"""Return the policy bag for the bound instance, else the process fallback.""" """Return the bound instance or raise :class:`WorkspaceError`."""
instance = _bound_instance() instance = _bound_instance()
if instance is not None: if instance is None:
return instance.workspace msg = "instance state is not bound; host must set InstanceState around tool execution"
return _process_policy raise WorkspaceError(msg)
return instance
def active_workspace_policy() -> WorkspacePolicy:
"""Return the policy bag for the bound instance (required)."""
return require_bound_instance().workspace
def set_workspace_root(root: Path | str) -> Path: def set_workspace_root(root: Path | str) -> Path:
"""Set the workspace root used by tools; returns the resolved root. """Set the workspace root on the bound instance; returns the resolved root."""
Writes the active policy bag (instance when bound, else process) and keeps
``instance.workspace_root`` in sync for callers that read the facet.
"""
path = Path(root).expanduser().resolve() path = Path(root).expanduser().resolve()
if not path.is_dir(): if not path.is_dir():
msg = f"workspace root is not a directory: {path}" msg = f"workspace root is not a directory: {path}"
raise WorkspaceError(msg) raise WorkspaceError(msg)
policy = active_workspace_policy() instance = require_bound_instance()
policy.root = path instance.workspace.root = path
instance = _bound_instance() instance.workspace_root = path
if instance is not None:
instance.workspace_root = path
# When bound, also keep process root as a bootstrapped default for tools
# that briefly run without context (compat).
if _process_policy.root is None:
_process_policy.root = path
else:
_process_policy.root = path
return path return path
def get_workspace_root() -> Path: def get_workspace_root() -> Path:
"""Return the configured workspace root. """Return the bound instance workspace root."""
instance = require_bound_instance()
Prefers a bound instance's ``workspace_root`` / policy root; otherwise the if instance.workspace_root is not None:
process-global root from :func:`set_workspace_root`. return instance.workspace_root
""" if instance.workspace.root is not None:
instance = _bound_instance() return instance.workspace.root
if instance is not None: msg = "workspace root is not set on the bound instance"
if instance.workspace_root is not None: raise WorkspaceError(msg)
return instance.workspace_root
if instance.workspace.root is not None:
return instance.workspace.root
if _process_policy.root is None:
msg = "workspace root is not set; call set_workspace_root() first"
raise WorkspaceError(msg)
return _process_policy.root
def clear_workspace_root() -> None: def clear_workspace_root() -> None:
"""Clear workspace root (mainly for tests). Does not clear allowlist. """Clear workspace root on the bound instance (mainly for tests)."""
instance = require_bound_instance()
When an instance is bound, clears both the instance facet and the process instance.workspace.root = None
fallback so ``get_workspace_root`` cannot revive the old process root. instance.workspace_root = None
"""
policy = active_workspace_policy()
policy.root = None
instance = _bound_instance()
if instance is not None:
instance.workspace_root = None
instance.workspace.root = None
_process_policy.root = None
def set_path_denylist(patterns: list[str] | tuple[str, ...] | None) -> None: def set_path_denylist(patterns: list[str] | tuple[str, ...] | None) -> None:
@@ -155,7 +129,6 @@ def set_command_denylist(names: list[str] | tuple[str, ...] | frozenset[str] | N
"""Set denied command basenames (None restores defaults).""" """Set denied command basenames (None restores defaults)."""
policy = active_workspace_policy() policy = active_workspace_policy()
policy.command_denylist = DEFAULT_COMMAND_DENYLIST if names is None else frozenset(names) policy.command_denylist = DEFAULT_COMMAND_DENYLIST if names is None else frozenset(names)
# Session overrides only make sense against the active denylist.
policy.policy_allowed_commands &= policy.command_denylist policy.policy_allowed_commands &= policy.command_denylist
@@ -190,7 +163,7 @@ def clear_policy_allowed_commands() -> None:
def grant_policy_command(basename: str) -> None: def grant_policy_command(basename: str) -> None:
"""Allow *basename* for the rest of this process despite the denylist.""" """Allow *basename* for this instance despite the denylist."""
name = basename.strip() name = basename.strip()
if name: if name:
active_workspace_policy().policy_allowed_commands.add(name) active_workspace_policy().policy_allowed_commands.add(name)
@@ -249,22 +222,17 @@ def remove_workspace_allowlist(root: Path | str) -> None:
policy.allowlist.remove(path) policy.allowlist.remove(path)
def _primary_roots(policy: WorkspacePolicy) -> list[Path]: def _primary_roots(instance: InstanceState, policy: WorkspacePolicy) -> list[Path]:
"""Primary workspace roots: bound instance facet then policy root."""
roots: list[Path] = [] roots: list[Path] = []
instance = _bound_instance() if instance.workspace_root is not None:
if instance is not None and instance.workspace_root is not None:
roots.append(instance.workspace_root) roots.append(instance.workspace_root)
if policy.root is not None and policy.root not in roots: if policy.root is not None and policy.root not in roots:
roots.append(policy.root) roots.append(policy.root)
# Process root as last resort when instance policy has no root yet.
if _process_policy.root is not None and _process_policy.root not in roots:
roots.append(_process_policy.root)
return roots return roots
def _under_any_root(resolved: Path, policy: WorkspacePolicy) -> bool: def _under_any_root(resolved: Path, instance: InstanceState, policy: WorkspacePolicy) -> bool:
roots = _primary_roots(policy) roots = _primary_roots(instance, policy)
roots.extend(policy.allowlist) roots.extend(policy.allowlist)
for root in roots: for root in roots:
try: try:
@@ -281,13 +249,14 @@ def resolve_path(path: str | Path) -> Path:
Relative paths resolve against the **primary** workspace root. Absolute Relative paths resolve against the **primary** workspace root. Absolute
paths may also land under a temporary workspace allowlist entry. paths may also land under a temporary workspace allowlist entry.
""" """
policy = active_workspace_policy() instance = require_bound_instance()
policy = instance.workspace
root = get_workspace_root() root = get_workspace_root()
candidate = Path(path) candidate = Path(path)
if not candidate.is_absolute(): if not candidate.is_absolute():
candidate = root / candidate candidate = root / candidate
resolved = candidate.expanduser().resolve() resolved = candidate.expanduser().resolve()
if not _under_any_root(resolved, policy): if not _under_any_root(resolved, instance, policy):
msg = f"path escapes workspace root ({root}): {path}" msg = f"path escapes workspace root ({root}): {path}"
raise WorkspaceError(msg) raise WorkspaceError(msg)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows. # Normalize separators so denylist entries like ``/secrets/`` match on Windows.
-4
View File
@@ -18,7 +18,6 @@ from plyngent.lmproto.openai_compatible.model import (
UserChatMessage, UserChatMessage,
) )
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
from plyngent.tools import set_workspace_root
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@@ -59,7 +58,6 @@ class SummaryClient:
async def test_compact_to_new_session(tmp_path: Path) -> None: async def test_compact_to_new_session(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig()) memory = await MemoryStore.open(DatabaseConfig())
try: try:
provider = OpenAIProvider(access_key_or_token="sk-test") provider = OpenAIProvider(access_key_or_token="sk-test")
@@ -131,7 +129,6 @@ async def test_compact_to_new_session(tmp_path: Path) -> None:
async def test_rebuild_client_preserves_persist_cursor(tmp_path: Path) -> None: async def test_rebuild_client_preserves_persist_cursor(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig()) memory = await MemoryStore.open(DatabaseConfig())
try: try:
provider = OpenAIProvider(access_key_or_token="sk-test") provider = OpenAIProvider(access_key_or_token="sk-test")
@@ -166,7 +163,6 @@ async def test_rebuild_client_preserves_persist_cursor(tmp_path: Path) -> None:
async def test_compact_empty_fails(tmp_path: Path) -> None: async def test_compact_empty_fails(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig()) memory = await MemoryStore.open(DatabaseConfig())
try: try:
provider = OpenAIProvider(access_key_or_token="sk-test") provider = OpenAIProvider(access_key_or_token="sk-test")
-4
View File
@@ -41,13 +41,9 @@ def test_format_tool_confirm_box_multiline() -> None:
def test_install_cli_limit_hooks() -> None: def test_install_cli_limit_hooks() -> None:
from plyngent.tools.workspace import get_policy_confirm_hook, set_policy_confirm_hook
install_cli_limit_hooks() install_cli_limit_hooks()
assert callable(getattr(PtyManager, "_limit_continue", None)) assert callable(getattr(PtyManager, "_limit_continue", None))
assert get_policy_confirm_hook() is not None
PtyManager.set_limit_continue_hook(None) PtyManager.set_limit_continue_hook(None)
set_policy_confirm_hook(None)
# Backend remains usable after install. # Backend remains usable after install.
assert get_prompt_backend().is_interactive() or True assert get_prompt_backend().is_interactive() or True
-3
View File
@@ -18,7 +18,6 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionsParam, ChatCompletionsParam,
) )
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
from plyngent.tools import set_workspace_root
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@@ -68,7 +67,6 @@ class DummyClient:
@pytest.fixture @pytest.fixture
async def state(tmp_path: Path) -> AsyncIterator[ReplState]: async def state(tmp_path: Path) -> AsyncIterator[ReplState]:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig()) memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test") provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document()) config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
@@ -433,7 +431,6 @@ async def test_yolo_toggle(state: ReplState, capsys: pytest.CaptureFixture[str])
async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None: async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig()) memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test") provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document()) config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
-2
View File
@@ -19,7 +19,6 @@ from plyngent.lmproto.openai_compatible.model import (
) )
from plyngent.memory import MemoryStore from plyngent.memory import MemoryStore
from plyngent.memory.database.store import normalize_workspace from plyngent.memory.database.store import normalize_workspace
from plyngent.tools import set_workspace_root
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@@ -70,7 +69,6 @@ class DummyClient:
def _make_state(memory: MemoryStore, workspace: Path) -> ReplState: def _make_state(memory: MemoryStore, workspace: Path) -> ReplState:
_ = set_workspace_root(workspace)
provider = OpenAIProvider(access_key_or_token="sk-test") provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=workspace / "plyngent.toml", document=tomlkit.document()) config = ConfigStore(path=workspace / "plyngent.toml", document=tomlkit.document())
config.providers = {"local": provider} config.providers = {"local": provider}
+9 -6
View File
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
import pytest import pytest
from plyngent.tools.context import InstanceState, bind_instance
from plyngent.tools.workspace import ( from plyngent.tools.workspace import (
clear_workspace_allowlist, clear_workspace_allowlist,
clear_workspace_root, clear_workspace_root,
@@ -17,9 +18,11 @@ if TYPE_CHECKING:
@pytest.fixture @pytest.fixture
def workspace(tmp_path: Path) -> Iterator[Path]: def workspace(tmp_path: Path) -> Iterator[Path]:
clear_workspace_root() """Bind an InstanceState workspace for tool tests (no process globals)."""
clear_workspace_allowlist() instance = InstanceState(workspace_root=tmp_path.resolve())
root = set_workspace_root(tmp_path) with bind_instance(instance):
yield root clear_workspace_allowlist()
clear_workspace_allowlist() root = set_workspace_root(tmp_path)
clear_workspace_root() yield root
clear_workspace_allowlist()
clear_workspace_root()
+10 -5
View File
@@ -5,16 +5,21 @@ from __future__ import annotations
import inspect import inspect
from plyngent.agent import ToolTag, tool 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.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() register_builtin_tools()
selected = default_tool_definitions(surface="local") selected = default_tool_definitions(surface="local")
legacy = [t.name for t in DEFAULT_TOOLS] groups = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS, *TODO_TOOLS]
assert sorted(t.name for t in selected) == sorted(legacy) assert sorted(t.name for t in selected) == sorted(t.name for t in groups)
assert len(selected) == len(legacy) assert len(selected) == len(groups)
def test_all_builtins_are_async_and_tagged() -> None: 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: 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) instance = InstanceState(workspace_root=tmp_path.resolve())
try: with bind_instance(instance):
_ = set_workspace_root(tmp_path)
# New file: no soft-confirm # New file: no soft-confirm
assert classify_danger("write_file", {"path": "new.txt"}) is None assert classify_danger("write_file", {"path": "new.txt"}) is None
# Partial edits: never soft-confirm # 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 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}) creason = classify_danger("copy_path", {"src": "src.txt", "dst": "dst.txt", "overwrite": True})
assert creason is not None and "overwrite" in creason assert creason is not None and "overwrite" in creason
finally:
clear_workspace_root()
def test_classify_safe_tools() -> None: 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 __future__ import annotations
from plyngent.agent import ToolRegistry from plyngent.agent import ToolRegistry
from plyngent.agent.todo_stack import TodoStack from plyngent.agent.todo_stack import TodoStack
from plyngent.tools.context import SessionState, bind_tool_context 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 from plyngent.tools.view import MemoryViewStore, session_data_view
async def test_todo_tools_session_data_without_process_bind() -> None: async def test_todo_tools_session_data() -> None:
set_todo_stack(None)
assert get_todo_stack() is None
store = MemoryViewStore({}) store = MemoryViewStore({})
session = SessionState(session_id="s1", data=session_data_view(store=store)) session = SessionState(session_id="s1", data=session_data_view(store=store))
registry = ToolRegistry(list(TODO_TOOLS), session_state=session) 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: async def test_todo_tools_prefer_session_todo_facet() -> None:
stack = TodoStack() stack = TodoStack()
set_todo_stack(stack) # process bind present
store = MemoryViewStore({}) store = MemoryViewStore({})
session = SessionState(session_id="s2", data=session_data_view(store=store), todo=stack) session = SessionState(session_id="s2", data=session_data_view(store=store), todo=stack)
registry = ToolRegistry(list(TODO_TOOLS), session_state=session) 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() loaded = await store.load()
assert isinstance(loaded, dict) assert isinstance(loaded, dict)
assert isinstance(loaded.get("todo"), dict) assert isinstance(loaded.get("todo"), dict)
set_todo_stack(None)
async def test_todo_tools_view_isolation_two_sessions() -> None: async def test_todo_tools_view_isolation_two_sessions() -> None:
set_todo_stack(None)
store_a = MemoryViewStore({}) store_a = MemoryViewStore({})
store_b = MemoryViewStore({}) store_b = MemoryViewStore({})
session_a = SessionState(session_id="a", data=session_data_view(store=store_a)) 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")) 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_a.all_items()] == ["only-a"]
assert [i.title for i in raw_b.all_items()] == ["only-b"] 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 assert session_a.todo is not session_b.todo
async def test_with_bound_context_without_registry_session() -> None: async def test_with_bound_context_without_registry_session() -> None:
"""Handlers honor contextvars when registry does not hold session_state.""" """Handlers honor contextvars when registry does not hold session_state."""
set_todo_stack(None)
store = MemoryViewStore({}) store = MemoryViewStore({})
session = SessionState(session_id="ctx", data=session_data_view(store=store)) session = SessionState(session_id="ctx", data=session_data_view(store=store))
registry = ToolRegistry(list(TODO_TOOLS), auto_bind_state=False) 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) assert isinstance(loaded.get("todo"), dict)
async def test_session_on_todo_change_preferred_over_process() -> None: async def test_session_on_todo_change_fires() -> None:
"""Session.on_todo_change fires instead of process set_todo_stack on_change."""
set_todo_stack(None)
hits: list[str] = [] hits: list[str] = []
def process_hook() -> None:
hits.append("process")
def session_hook() -> None: def session_hook() -> None:
hits.append("session") hits.append("session")
stack = TodoStack() stack = TodoStack()
set_todo_stack(stack, on_change=process_hook)
store = MemoryViewStore({}) store = MemoryViewStore({})
session = SessionState( session = SessionState(
session_id="hook", 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) registry = ToolRegistry(list(TODO_TOOLS), session_state=session)
_ = await registry.execute("todo_push", '{"titles": ["H"]}') _ = await registry.execute("todo_push", '{"titles": ["H"]}')
assert hits == ["session"] 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 ( from plyngent.tools import (
WorkspaceError, WorkspaceError,
check_command_allowed, check_command_allowed,
clear_workspace_root,
get_workspace_root, get_workspace_root,
resolve_path, resolve_path,
set_command_denylist, set_command_denylist,
@@ -129,6 +128,12 @@ def test_command_denylist_policy_confirm_timeout_value(workspace: object) -> Non
def test_root_required() -> None: def test_root_required() -> None:
clear_workspace_root() from plyngent.tools.context import InstanceState, bind_instance
with pytest.raises(WorkspaceError, match="not set"):
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() _ = 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 from __future__ import annotations
@@ -16,26 +16,18 @@ from plyngent.tools.workspace import (
) )
def test_get_workspace_prefers_instance(tmp_path: Path) -> None: def test_unbound_get_workspace_errors() -> None:
clear_workspace_root() with pytest.raises(WorkspaceError, match="instance state is not bound"):
other = tmp_path / "other" _ = get_workspace_root()
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_set_workspace_mirrors_to_bound_instance(tmp_path: Path) -> None: def test_set_workspace_on_bound_instance(tmp_path: Path) -> None:
clear_workspace_root()
instance = InstanceState() instance = InstanceState()
with bind_instance(instance): with bind_instance(instance):
path = set_workspace_root(tmp_path) path = set_workspace_root(tmp_path)
assert instance.workspace_root == path assert instance.workspace_root == path
assert instance.workspace.root == path
assert get_workspace_root() == path assert get_workspace_root() == path
clear_workspace_root()
def test_clear_clears_bound_instance(tmp_path: Path) -> None: 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) _ = set_workspace_root(tmp_path)
clear_workspace_root() clear_workspace_root()
assert instance.workspace_root is None assert instance.workspace_root is None
with pytest.raises(WorkspaceError): with pytest.raises(WorkspaceError, match="workspace root is not set"):
_ = get_workspace_root() _ = get_workspace_root()
def test_resolve_path_accepts_instance_root_without_process_global(tmp_path: Path) -> None: def test_resolve_path_uses_instance_root(tmp_path: Path) -> None:
"""Instance-only workspace must be a valid resolve root (not only process global)."""
clear_workspace_root()
target = tmp_path / "note.txt" target = tmp_path / "note.txt"
_ = target.write_text("hi", encoding="utf-8") _ = target.write_text("hi", encoding="utf-8")
instance = InstanceState(workspace_root=tmp_path.resolve()) instance = InstanceState(workspace_root=tmp_path.resolve())
with bind_instance(instance): with bind_instance(instance):
resolved = resolve_path("note.txt") resolved = resolve_path("note.txt")
assert resolved == target.resolve() assert resolved == target.resolve()
clear_workspace_root()
def test_path_denylist_uses_instance_policy(tmp_path: Path) -> None: 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 = tmp_path / "secrets"
secret.mkdir() secret.mkdir()
_ = (secret / "x.txt").write_text("no", encoding="utf-8") _ = (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/",) instance.workspace.path_denylist = ("/secrets/",)
with bind_instance(instance), pytest.raises(WorkspaceError, match="denied by policy"): with bind_instance(instance), pytest.raises(WorkspaceError, match="denied by policy"):
_ = resolve_path("secrets/x.txt") _ = resolve_path("secrets/x.txt")
clear_workspace_root()