From f31daf1949b8cc30ef3533d8618b07655c001104 Mon Sep 17 00:00:00 2001 From: jinliyl <6469360+jinliyl@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:17:08 +0800 Subject: [PATCH] Revert "feat(backend): improve workspace support for web clients (#417)" (#419) This reverts commit b00eb0a9ea292924d57915fc42477f0697525b54. --- .../agent_wrapper/as_agent_wrapper.py | 86 +------------------ reme/config/default.yaml | 12 --- reme/schema/application_config.py | 15 +--- reme/steps/common/chat.py | 29 +------ reme/steps/file_io/list.py | 38 ++------ tests/unit/test_base_agent_wrapper.py | 36 -------- tests/unit/test_crud_steps.py | 23 ----- tests/unit/test_workspace_web_steps.py | 42 ++------- 8 files changed, 22 insertions(+), 259 deletions(-) diff --git a/reme/components/agent_wrapper/as_agent_wrapper.py b/reme/components/agent_wrapper/as_agent_wrapper.py index 7360543c..3b539bf5 100644 --- a/reme/components/agent_wrapper/as_agent_wrapper.py +++ b/reme/components/agent_wrapper/as_agent_wrapper.py @@ -123,86 +123,6 @@ class WorkspaceBackend(LocalBackend): return ExecResult(exit_code=process.returncode or 0, stdout=stdout, stderr=stderr) -class WorkspaceRead(Read): - """AgentScope Read accepting paths relative to the configured agent workspace.""" - - description = Read.description.replace( - "must be an absolute path, not a relative path", - "may be absolute or relative to the workspace", - ) - input_schema = { - **Read.input_schema, - "properties": { - **Read.input_schema["properties"], - "file_path": { - "type": "string", - "description": "Absolute path or path relative to the workspace.", - }, - }, - } - - async def call( - self, - file_path: str, - offset: int = 1, - limit: int = 2000, - _agent_state: AgentState | None = None, - ) -> ToolChunk: - """Resolve a workspace-relative path before delegating to AgentScope.""" - cwd = await self._backend.getcwd() # pylint: disable=protected-access - target = self._backend.abspath(file_path, cwd=cwd) # pylint: disable=protected-access - return await super().call(target, offset, limit, _agent_state) - - -class WorkspaceWrite(Write): - """AgentScope Write accepting paths relative to the configured agent workspace.""" - - input_schema = { - **Write.input_schema, - "properties": { - **Write.input_schema["properties"], - "file_path": { - "type": "string", - "description": "Absolute path or path relative to the workspace.", - }, - }, - } - - async def call(self, file_path: str, content: str, _agent_state: AgentState | None = None) -> ToolChunk: - """Resolve a workspace-relative path before delegating to AgentScope.""" - cwd = await self._backend.getcwd() # pylint: disable=protected-access - target = self._backend.abspath(file_path, cwd=cwd) # pylint: disable=protected-access - return await super().call(target, content, _agent_state) - - -class WorkspaceEdit(Edit): - """AgentScope Edit accepting paths relative to the configured agent workspace.""" - - input_schema = { - **Edit.input_schema, - "properties": { - **Edit.input_schema["properties"], - "file_path": { - "type": "string", - "description": "Absolute path or path relative to the workspace.", - }, - }, - } - - async def call( - self, - file_path: str, - old_string: str, - new_string: str, - replace_all: bool = False, - _agent_state: AgentState | None = None, - ) -> ToolChunk: - """Resolve a workspace-relative path before delegating to AgentScope.""" - cwd = await self._backend.getcwd() # pylint: disable=protected-access - target = self._backend.abspath(file_path, cwd=cwd) # pylint: disable=protected-access - return await super().call(target, old_string, new_string, replace_all, _agent_state) - - class BypassAnalysisBash(Bash): """Bash variant that delegates permission decisions to PermissionEngine. @@ -272,11 +192,11 @@ class AsAgentWrapper(BaseAgentWrapper): backend = WorkspaceBackend(cwd, self.bash_environment) factories = { "bash": lambda: BypassAnalysisBash(cwd=cwd, backend=backend), - "edit": lambda: WorkspaceEdit(backend=backend), + "edit": lambda: Edit(backend=backend), "glob": lambda: Glob(backend=backend), "grep": lambda: Grep(backend=backend), - "read": lambda: WorkspaceRead(backend=backend), - "write": lambda: WorkspaceWrite(backend=backend), + "read": lambda: Read(backend=backend), + "write": lambda: Write(backend=backend), } if names is False: selected_names = [] diff --git a/reme/config/default.yaml b/reme/config/default.yaml index 36036b65..30b29f1a 100644 --- a/reme/config/default.yaml +++ b/reme/config/default.yaml @@ -1,5 +1,3 @@ -workspace_dir: ~/.copaw/workspaces/default/ - service: backend: http @@ -505,16 +503,6 @@ jobs: type: boolean description: "recurse" default: false - sort_by: - type: string - enum: - - mtime - description: "optional ordering; mtime returns most recently modified files first" - extensions: - type: array - items: - type: string - description: "optional extension allowlist applied before sorting and limiting" limit: type: integer description: "max results" diff --git a/reme/schema/application_config.py b/reme/schema/application_config.py index ff1b8697..00a0b286 100644 --- a/reme/schema/application_config.py +++ b/reme/schema/application_config.py @@ -1,9 +1,8 @@ """Application configuration models.""" import os -from pathlib import Path -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field from ..enumeration import ComponentEnum @@ -33,11 +32,7 @@ class ApplicationConfig(BaseModel): default_factory=dict, description="Environment variables loaded once at startup and passed to agent subprocesses", ) - workspace_dir: str = Field( - default=".reme", - description="Workspace root directory for runtime files", - validate_default=True, - ) + workspace_dir: str = Field(default=".reme", description="Workspace root directory for runtime files") metadata_dir: str = Field(default="metadata", description="Subdirectory for ReMe persistent state") session_dir: str = Field(default="session", description="Subdirectory for persisted agent sessions") mem_session_dir: str = Field(default="mem_session", description="Subdirectory for persisted agent sessions") @@ -58,9 +53,3 @@ class ApplicationConfig(BaseModel): default_factory=dict, description="Component registry keyed by type then name", ) - - @field_validator("workspace_dir", mode="before") - @classmethod - def normalize_workspace_dir(cls, value) -> str: - """Expand home-relative paths once so every component sees the same absolute workspace.""" - return str(Path(value).expanduser().resolve(strict=False)) diff --git a/reme/steps/common/chat.py b/reme/steps/common/chat.py index bf8002de..56f18de1 100644 --- a/reme/steps/common/chat.py +++ b/reme/steps/common/chat.py @@ -1,8 +1,5 @@ """Workspace-aware streaming chat step for the ReMe web interface.""" -import datetime -import zoneinfo - from ..base_step import BaseStep from ...components import R from ...enumeration import ChunkEnum @@ -16,25 +13,7 @@ class ChatStep(BaseStep): Use the available ReMe tools when workspace facts are needed. Cite workspace-relative file paths when referring to notes. Never invent file contents, and do not claim to have changed files because this chat intentionally provides read-only tools. Reply in the user's language.""" - READ_ONLY_TOOLS = ["search", "list", "stat", "traverse"] - - def _system_prompt(self) -> str: - """Append request-time environment facts to the configured prompt.""" - timezone = self.app_context.app_config.timezone if self.app_context is not None else None - try: - current = datetime.datetime.now(zoneinfo.ZoneInfo(timezone)) if timezone else datetime.datetime.now() - except (zoneinfo.ZoneInfoNotFoundError, ValueError): - current = datetime.datetime.now() - - assert self.context is not None - base_prompt = str(self.context.get("system_prompt") or self.DEFAULT_SYSTEM_PROMPT).rstrip() - return ( - f"{base_prompt}\n\n" - "\n" - f"Current date: {current.date().isoformat()}\n" - f"Current working directory: {self.agent_wrapper.cwd.resolve(strict=False)}\n" - "" - ) + READ_ONLY_TOOLS = ["search", "list", "read", "stat", "traverse"] async def execute(self): assert self.context is not None @@ -47,7 +26,7 @@ this chat intentionally provides read-only tools. Reply in the user's language." raise RuntimeError("chat_step requires an agent_wrapper") wrapper_kwargs = { - "system_prompt": self._system_prompt(), + "system_prompt": self.context.get("system_prompt") or self.DEFAULT_SYSTEM_PROMPT, "job_tools": self.READ_ONLY_TOOLS, } if session_id := self.context.get("session_id"): @@ -55,10 +34,6 @@ this chat intentionally provides read-only tools. Reply in the user's language." parts: list[str] = [] async for chunk in self.agent_wrapper.reply_stream(query, **wrapper_kwargs): - if chunk.chunk_type == ChunkEnum.REPLY_END: - answer = "".join(parts).strip() - if answer: - chunk.metadata["answer"] = answer await self.context.add_stream_chunk(chunk) if chunk.chunk_type == ChunkEnum.CONTENT and isinstance(chunk.chunk, str): parts.append(chunk.chunk) diff --git a/reme/steps/file_io/list.py b/reme/steps/file_io/list.py index cee573ba..ebacc967 100644 --- a/reme/steps/file_io/list.py +++ b/reme/steps/file_io/list.py @@ -8,8 +8,6 @@ Parameters: path — dir to list under (relative to the workspace or absolute). Empty = workspace root. limit — cap on the number of returned items (default 100, must be > 0). recursive — descend into subdirectories. Default False = direct children only. - sort_by — optional ordering; ``mtime`` returns most recently modified files first. - extensions — optional extension allowlist applied before sorting and limiting. No frontmatter is read. Callers needing frontmatter-based filtering should iterate the result and call ``frontmatter_read`` per candidate. @@ -38,7 +36,7 @@ class ListStep(BaseStep): if meta: self.context.response.metadata.update(meta) - def _collect_params(self) -> tuple[str, bool, int, str, frozenset[str]]: + def _collect_params(self) -> tuple[str, bool, int]: """Read ``path`` / ``recursive`` / ``limit`` from context; coerce permissively.""" assert self.context is not None path = str(self.context.get("path") or "") @@ -49,37 +47,20 @@ class ListStep(BaseStep): limit = int(raw_limit) if raw_limit is not None else DEFAULT_LIMIT except (TypeError, ValueError): limit = DEFAULT_LIMIT - sort_by = str(self.context.get("sort_by") or "") - raw_extensions = self.context.get("extensions") or [] - if isinstance(raw_extensions, str): - raw_extensions = raw_extensions.split(",") - extensions = frozenset( - normalized for value in raw_extensions if (normalized := str(value).strip().lower().lstrip(".")) - ) - return path, recursive, limit if limit > 0 else DEFAULT_LIMIT, sort_by, extensions + return path, recursive, limit if limit > 0 else DEFAULT_LIMIT @staticmethod - def _walk_files( - target_dir: Path, - recursive: bool, - limit: int, - sort_by: str = "", - extensions: frozenset[str] = frozenset(), - ) -> list[Path]: - """Return up to ``limit`` regular files, scanning all entries only when sorting.""" + def _walk_files(target_dir: Path, recursive: bool, limit: int) -> list[Path]: + """Return up to ``limit`` regular files under ``target_dir``; short-circuits at the cap.""" entries: Iterable[Path] = target_dir.rglob("*") if recursive else target_dir.iterdir() files: list[Path] = [] for entry in entries: if not entry.is_file(): # skip dirs, sockets, broken links, etc. continue - if extensions and entry.suffix.lower().lstrip(".") not in extensions: - continue files.append(entry) - if sort_by != "mtime" and len(files) >= limit: + if len(files) >= limit: break - if sort_by == "mtime": - files.sort(key=lambda entry: (-entry.stat().st_mtime, entry.as_posix())) - return files[:limit] + return files @staticmethod def _format_relative(files: list[Path], workspace_dir: Path) -> list[str]: @@ -94,7 +75,7 @@ class ListStep(BaseStep): async def execute(self): assert self.context is not None - path, recursive, limit, sort_by, extensions = self._collect_params() + path, recursive, limit = self._collect_params() workspace_dir = Path(self.file_store.workspace_path or ".").resolve() target_dir, err = resolve_path(workspace_dir, path, allow_empty=True) if err or target_dir is None: @@ -108,10 +89,7 @@ class ListStep(BaseStep): self._fail(f"path {target_dir} is not a directory", path=str(target_dir)) return None - items = self._format_relative( - self._walk_files(target_dir, recursive, limit, sort_by, extensions), - workspace_dir, - ) + items = self._format_relative(self._walk_files(target_dir, recursive, limit), workspace_dir) self.context.response.success = True location = path or "." diff --git a/tests/unit/test_base_agent_wrapper.py b/tests/unit/test_base_agent_wrapper.py index 53fda2bb..17a1b1fa 100644 --- a/tests/unit/test_base_agent_wrapper.py +++ b/tests/unit/test_base_agent_wrapper.py @@ -139,42 +139,6 @@ async def test_agentscope_backend_passes_configured_environment_to_bash(tmp_path assert result.stdout == b"configured\n" -def test_workspace_dir_expands_user_home(monkeypatch, tmp_path): - """Home-relative workspace config is normalized before components consume it.""" - monkeypatch.setenv("HOME", str(tmp_path)) - - context = ApplicationContext(workspace_dir="~/.copaw/workspaces/default") - - assert context.app_config.workspace_dir == str(tmp_path / ".copaw/workspaces/default") - - -@pytest.mark.asyncio -async def test_agentscope_file_tools_accept_workspace_relative_paths(tmp_path): - """Built-in Read/Write/Edit share the workspace-relative path convention used by ReMe jobs.""" - wrapper = AsAgentWrapper( - app_context=ApplicationContext(workspace_dir=str(tmp_path)), - as_llm="", - ) - read, write, edit = wrapper._builtin_tools(["read", "write", "edit"]) # pylint: disable=protected-access - source = tmp_path / ".reme/daily/note.md" - source.parent.mkdir(parents=True) - source.write_text("before", encoding="utf-8") - - read_result = await read.call(file_path=".reme/daily/note.md") - write_result = await write.call(file_path=".reme/daily/new.md", content="new") - edit_result = await edit.call( - file_path=".reme/daily/note.md", - old_string="before", - new_string="after", - ) - - assert "absolute path" not in str(read_result.content) - assert "absolute path" not in str(write_result.content) - assert "absolute path" not in str(edit_result.content) - assert (tmp_path / ".reme/daily/new.md").read_text(encoding="utf-8") == "new" - assert source.read_text(encoding="utf-8") == "after" - - @pytest.mark.asyncio async def test_agentscope_bash_uses_managed_proxy_without_changing_subprocess_environment(tmp_path): """AgentScope applies the managed proxy only to its command backend.""" diff --git a/tests/unit/test_crud_steps.py b/tests/unit/test_crud_steps.py index 0eb4d98e..1132a686 100644 --- a/tests/unit/test_crud_steps.py +++ b/tests/unit/test_crud_steps.py @@ -225,29 +225,6 @@ def test_list_respects_limit_and_non_recursive(): asyncio.run(run()) -def test_list_can_sort_by_most_recent_modification(): - """Filtering precedes the limit so unrelated generated files cannot hide recent notes.""" - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - older = _seed_md(root, "older.md", "old") - newer = _seed_md(root, "newer.md", "new") - generated = root / "newest.json" - generated.write_text("{}", encoding="utf-8") - os.utime(older, (100, 100)) - os.utime(newer, (200, 200)) - os.utime(generated, (300, 300)) - - files = crud_list.ListStep._walk_files( - root, - recursive=True, - limit=2, - sort_by="mtime", - extensions=frozenset({"md"}), - ) - - assert [path.name for path in files] == ["newer.md", "older.md"] - - # -- download ------------------------------------------------------------ # # DownloadStep lives in reme_cc (the local plugin overlay), not reme diff --git a/tests/unit/test_workspace_web_steps.py b/tests/unit/test_workspace_web_steps.py index ad592c2a..7071ce8e 100644 --- a/tests/unit/test_workspace_web_steps.py +++ b/tests/unit/test_workspace_web_steps.py @@ -3,7 +3,6 @@ import asyncio import os from datetime import datetime -from zoneinfo import ZoneInfo from reme.components.agent_wrapper import BaseAgentWrapper from reme.components.application_context import ApplicationContext @@ -18,8 +17,8 @@ from reme.steps.file_io.save import SaveStep class _StreamingAgent(BaseAgentWrapper): """Minimal agent that records resume/tool arguments and emits rich chunks.""" - def __init__(self, **kwargs): - super().__init__(**kwargs) + def __init__(self): + super().__init__() self.reply_kwargs = {} async def reply(self, inputs, **kwargs) -> dict: @@ -29,7 +28,6 @@ class _StreamingAgent(BaseAgentWrapper): self.reply_kwargs = kwargs yield StreamChunk(chunk_type=ChunkEnum.TOOL_CALL, chunk="{}", tool_call_id="tool-1", tool_call_name="search") yield StreamChunk(chunk_type=ChunkEnum.CONTENT, chunk="hello", session_id="session-new") - yield StreamChunk(chunk_type=ChunkEnum.REPLY_END, chunk="", session_id="session-new") def test_save_step_preserves_complete_markdown(tmp_path): @@ -115,47 +113,21 @@ def test_save_step_rejects_external_change(tmp_path): asyncio.run(run()) -def test_chat_step_streams_rich_chunks_and_resumes_session(tmp_path): +def test_chat_step_streams_rich_chunks_and_resumes_session(): """Web chat keeps StreamChunk metadata and exposes only read-only tools.""" async def run(): - app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai") - agent = _StreamingAgent(app_context=app_context) + agent = _StreamingAgent() queue = asyncio.Queue() context = RuntimeContext(stream_queue=queue, query="hello", session_id="session-old") - response = await ChatStep(agent_wrapper=agent, app_context=app_context)(context) + response = await ChatStep(agent_wrapper=agent)(context) - chunks = [await queue.get(), await queue.get(), await queue.get()] + chunks = [await queue.get(), await queue.get()] assert response.answer == "hello" assert chunks[0].tool_call_name == "search" assert chunks[1].session_id == "session-new" - assert chunks[2].metadata["answer"] == "hello" assert agent.reply_kwargs["resume"] == "session-old" - assert agent.reply_kwargs["job_tools"] == ["search", "list", "stat", "traverse"] - system_prompt = agent.reply_kwargs["system_prompt"] - assert f"Current date: {datetime.now(ZoneInfo('Asia/Shanghai')).date().isoformat()}" in system_prompt - assert f"Current working directory: {tmp_path.resolve()}" in system_prompt - - asyncio.run(run()) - - -def test_chat_step_appends_environment_context_to_system_prompt_override(tmp_path): - """A caller prompt override retains request-time date and cwd context.""" - - async def run(): - app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai") - agent = _StreamingAgent(app_context=app_context) - - await ChatStep(agent_wrapper=agent, app_context=app_context)( - query="hello", - system_prompt="Custom prompt.", - stream_queue=asyncio.Queue(), - ) - - system_prompt = agent.reply_kwargs["system_prompt"] - assert system_prompt.startswith("Custom prompt.\n\n") - assert "Current date:" in system_prompt - assert f"Current working directory: {tmp_path.resolve()}" in system_prompt + assert agent.reply_kwargs["job_tools"] == ["search", "list", "read", "stat", "traverse"] asyncio.run(run())