mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-25 08:04:57 +08:00
core/tools: prefer instance workspace and session todo views
This commit is contained in:
@@ -7,12 +7,21 @@ from contextvars import ContextVar, Token
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from plyngent.tools.view import PersistentDataView, session_data_view
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from plyngent.agent.todo_stack import TodoStack
|
from plyngent.agent.todo_stack import TodoStack
|
||||||
from plyngent.tools.view import PersistentDataView
|
|
||||||
|
|
||||||
|
def _default_instance_data() -> PersistentDataView[Any]:
|
||||||
|
return session_data_view()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_session_data() -> PersistentDataView[Any]:
|
||||||
|
return session_data_view()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -22,7 +31,7 @@ class InstanceState:
|
|||||||
# Optional fixed facets (workspace path still also lives in workspace module
|
# Optional fixed facets (workspace path still also lives in workspace module
|
||||||
# during migration; hosts should set both until globals retire).
|
# during migration; hosts should set both until globals retire).
|
||||||
workspace_root: Path | None = None
|
workspace_root: Path | None = None
|
||||||
data: PersistentDataView[Any] | None = None
|
data: PersistentDataView[Any] = field(default_factory=_default_instance_data)
|
||||||
# Ephemeral process maps (PTY etc.) may hang here later.
|
# Ephemeral process maps (PTY etc.) may hang here later.
|
||||||
extras: dict[str, Any] = field(default_factory=dict)
|
extras: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
@@ -40,7 +49,7 @@ class SessionState:
|
|||||||
"""One chat session for SESSION_STATE tools."""
|
"""One chat session for SESSION_STATE tools."""
|
||||||
|
|
||||||
session_id: int | str | None = None
|
session_id: int | str | None = None
|
||||||
data: PersistentDataView[Any] | None = None
|
data: PersistentDataView[Any] = field(default_factory=_default_session_data)
|
||||||
# Live domain object during migration (also under data["todo"] when views bind).
|
# Live domain object during migration (also under data["todo"] when views bind).
|
||||||
todo: TodoStack | None = None
|
todo: TodoStack | None = None
|
||||||
# Soft-confirm grants: tool_name → True (Phase 1 key is tool name in session).
|
# Soft-confirm grants: tool_name → True (Phase 1 key is tool name in session).
|
||||||
|
|||||||
@@ -27,12 +27,26 @@ def get_todo_stack() -> TodoStack | None:
|
|||||||
|
|
||||||
|
|
||||||
def _require_stack() -> TodoStack:
|
def _require_stack() -> TodoStack:
|
||||||
"""Prefer session-bound todo; fall back to process bind during migration."""
|
"""Prefer session-bound todo; fall back to process bind during migration.
|
||||||
|
|
||||||
|
Order: ``session.todo`` facet → ``session.data["todo"].typed(TodoStack)`` when a
|
||||||
|
view is open/bound → process ``set_todo_stack`` global.
|
||||||
|
"""
|
||||||
|
from plyngent.agent.todo_stack import TodoStack
|
||||||
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.todo is not None:
|
if session is not None:
|
||||||
|
if session.todo is not None:
|
||||||
return session.todo
|
return session.todo
|
||||||
|
try:
|
||||||
|
# Prefer live domain object when a txn is open; otherwise skip.
|
||||||
|
todo = session.data["todo"].typed(TodoStack)
|
||||||
|
except RuntimeError:
|
||||||
|
todo = None
|
||||||
|
if isinstance(todo, TodoStack):
|
||||||
|
session.todo = todo
|
||||||
|
return todo
|
||||||
if _stack is None:
|
if _stack is None:
|
||||||
msg = "todo stack is not available in this session"
|
msg = "todo stack is not available in this session"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
|
|||||||
@@ -69,17 +69,35 @@ _state = _WorkspaceState()
|
|||||||
|
|
||||||
|
|
||||||
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 used by tools; returns the resolved root.
|
||||||
|
|
||||||
|
Writes the process-global root and, when an :class:`~plyngent.tools.context.InstanceState`
|
||||||
|
is bound, mirrors onto ``instance.workspace_root``.
|
||||||
|
"""
|
||||||
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)
|
||||||
_state.root = path
|
_state.root = path
|
||||||
|
from plyngent.tools.context import get_instance
|
||||||
|
|
||||||
|
instance = get_instance()
|
||||||
|
if instance is not None:
|
||||||
|
instance.workspace_root = path
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def get_workspace_root() -> Path:
|
def get_workspace_root() -> Path:
|
||||||
"""Return the configured workspace root."""
|
"""Return the configured workspace root.
|
||||||
|
|
||||||
|
Prefers a bound instance's ``workspace_root`` when set; otherwise the
|
||||||
|
process-global root from :func:`set_workspace_root`.
|
||||||
|
"""
|
||||||
|
from plyngent.tools.context import get_instance
|
||||||
|
|
||||||
|
instance = get_instance()
|
||||||
|
if instance is not None and instance.workspace_root is not None:
|
||||||
|
return instance.workspace_root
|
||||||
if _state.root is None:
|
if _state.root is None:
|
||||||
msg = "workspace root is not set; call set_workspace_root() first"
|
msg = "workspace root is not set; call set_workspace_root() first"
|
||||||
raise WorkspaceError(msg)
|
raise WorkspaceError(msg)
|
||||||
@@ -89,6 +107,11 @@ def get_workspace_root() -> Path:
|
|||||||
def clear_workspace_root() -> None:
|
def clear_workspace_root() -> None:
|
||||||
"""Clear workspace root (mainly for tests). Does not clear allowlist."""
|
"""Clear workspace root (mainly for tests). Does not clear allowlist."""
|
||||||
_state.root = None
|
_state.root = None
|
||||||
|
from plyngent.tools.context import get_instance
|
||||||
|
|
||||||
|
instance = get_instance()
|
||||||
|
if instance is not None:
|
||||||
|
instance.workspace_root = None
|
||||||
|
|
||||||
|
|
||||||
def set_path_denylist(patterns: list[str] | tuple[str, ...] | None) -> None:
|
def set_path_denylist(patterns: list[str] | tuple[str, ...] | None) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Workspace root prefers bound InstanceState when set."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plyngent.tools.context import InstanceState, bind_instance
|
||||||
|
from plyngent.tools.workspace import (
|
||||||
|
WorkspaceError,
|
||||||
|
clear_workspace_root,
|
||||||
|
get_workspace_root,
|
||||||
|
set_workspace_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_set_workspace_mirrors_to_bound_instance(tmp_path: Path) -> None:
|
||||||
|
clear_workspace_root()
|
||||||
|
instance = InstanceState()
|
||||||
|
with bind_instance(instance):
|
||||||
|
path = set_workspace_root(tmp_path)
|
||||||
|
assert instance.workspace_root == path
|
||||||
|
assert get_workspace_root() == path
|
||||||
|
clear_workspace_root()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_clears_bound_instance(tmp_path: Path) -> None:
|
||||||
|
instance = InstanceState()
|
||||||
|
with bind_instance(instance):
|
||||||
|
_ = set_workspace_root(tmp_path)
|
||||||
|
clear_workspace_root()
|
||||||
|
assert instance.workspace_root is None
|
||||||
|
with pytest.raises(WorkspaceError):
|
||||||
|
_ = get_workspace_root()
|
||||||
Reference in New Issue
Block a user