From e05b201da93960667aad4d491292b00307f31cf5 Mon Sep 17 00:00:00 2001
From: jinliyl <6469360+jinliyl@users.noreply.github.com>
Date: Fri, 7 Aug 2026 23:52:56 +0800
Subject: [PATCH] feat(backend): improve workspace support for web clients
(#420)
* feat(backend): improve workspace support for web clients
* fix(config): preserve the default workspace directory
* chore(reme): bump version to 0.4.1.5
- Update __version__ from 0.4.1.4 to 0.4.1.5 in initialization file
* fix(chat): disable builtin tools in read-only mode
* fix(agent): make builtin tools opt-in
* fix(list): tolerate files removed during mtime sort
* fix(chat): expose complete read-only job set
---
reme/__init__.py | 2 +-
.../agent_wrapper/as_agent_wrapper.py | 6 +-
reme/config/default.yaml | 10 +++
reme/schema/application_config.py | 13 +++-
reme/steps/common/chat.py | 30 ++++++++-
reme/steps/file_io/list.py | 51 +++++++++++---
tests/unit/test_application_config.py | 9 +++
tests/unit/test_base_agent_wrapper.py | 36 ++++++++++
tests/unit/test_crud_steps.py | 66 +++++++++++++++++++
tests/unit/test_workspace_web_steps.py | 51 ++++++++++++--
10 files changed, 251 insertions(+), 23 deletions(-)
diff --git a/reme/__init__.py b/reme/__init__.py
index d0a37a80..4a426226 100644
--- a/reme/__init__.py
+++ b/reme/__init__.py
@@ -1,6 +1,6 @@
"""ReMe CLI package."""
-__version__ = "0.4.1.4"
+__version__ = "0.4.1.5"
from . import config
from . import constants
diff --git a/reme/components/agent_wrapper/as_agent_wrapper.py b/reme/components/agent_wrapper/as_agent_wrapper.py
index 3b539bf5..06338989 100644
--- a/reme/components/agent_wrapper/as_agent_wrapper.py
+++ b/reme/components/agent_wrapper/as_agent_wrapper.py
@@ -314,9 +314,9 @@ class AsAgentWrapper(BaseAgentWrapper):
skills = self._resolve_skills(kwargs.get("skills"))
tool_context_id = kwargs.get("tool_context_id")
sequential_tool_calls = bool(kwargs.get("sequential_tool_calls", True))
- builtin_tools = kwargs.get("builtin_tools", "all")
- if "builtin_tools" not in kwargs and not bool(kwargs.get("use_builtin_tools", True)):
- builtin_tools = []
+ builtin_tools = kwargs.get("builtin_tools", [])
+ if "builtin_tools" not in kwargs and bool(kwargs.get("use_builtin_tools", False)):
+ builtin_tools = "all"
tools: list[ToolBase] = []
tools.extend(self._builtin_tools(builtin_tools, sequential_tool_calls=sequential_tool_calls))
tools.extend(self._make_tool(job, tool_context_id, kwargs.get("injected_job_kwargs")) for job in resolved_jobs)
diff --git a/reme/config/default.yaml b/reme/config/default.yaml
index 30b29f1a..a5f61f5a 100644
--- a/reme/config/default.yaml
+++ b/reme/config/default.yaml
@@ -503,6 +503,16 @@ 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 040df5be..5f008ce6 100644
--- a/reme/schema/application_config.py
+++ b/reme/schema/application_config.py
@@ -1,6 +1,7 @@
"""Application configuration models."""
import os
+from pathlib import Path
from pathlib import PurePosixPath, PureWindowsPath
from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -33,7 +34,11 @@ 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")
+ workspace_dir: str = Field(
+ default=".reme",
+ description="Workspace root directory for runtime files",
+ validate_default=True,
+ )
metadata_dir: str = Field(default="metadata", description="Subdirectory for ReMe persistent state")
session_dir: str = Field(default="session", description="Subdirectory for persisted agent sessions")
# dialog_dir was removed; standard transcripts are always derived as ``{session_dir}/dialog``.
@@ -55,6 +60,12 @@ class ApplicationConfig(BaseModel):
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))
+
@field_validator("session_dir")
@classmethod
def validate_session_dir(cls, value: str) -> str:
diff --git a/reme/steps/common/chat.py b/reme/steps/common/chat.py
index 56f18de1..17feda80 100644
--- a/reme/steps/common/chat.py
+++ b/reme/steps/common/chat.py
@@ -1,5 +1,8 @@
"""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
@@ -13,7 +16,25 @@ 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", "read", "stat", "traverse"]
+ READ_ONLY_TOOLS = ["search", "list", "read", "read_image", "frontmatter_read", "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"
+ ""
+ )
async def execute(self):
assert self.context is not None
@@ -26,14 +47,19 @@ 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.context.get("system_prompt") or self.DEFAULT_SYSTEM_PROMPT,
+ "system_prompt": self._system_prompt(),
"job_tools": self.READ_ONLY_TOOLS,
+ "builtin_tools": [],
}
if session_id := self.context.get("session_id"):
wrapper_kwargs["resume"] = str(session_id)
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 ebacc967..df217e81 100644
--- a/reme/steps/file_io/list.py
+++ b/reme/steps/file_io/list.py
@@ -8,12 +8,15 @@ 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.
"""
from pathlib import Path
+from stat import S_ISREG
from typing import Iterable
from ._path import resolve_path
@@ -36,7 +39,7 @@ class ListStep(BaseStep):
if meta:
self.context.response.metadata.update(meta)
- def _collect_params(self) -> tuple[str, bool, int]:
+ def _collect_params(self) -> tuple[str, bool, int, str, frozenset[str]]:
"""Read ``path`` / ``recursive`` / ``limit`` from context; coerce permissively."""
assert self.context is not None
path = str(self.context.get("path") or "")
@@ -47,20 +50,47 @@ class ListStep(BaseStep):
limit = int(raw_limit) if raw_limit is not None else DEFAULT_LIMIT
except (TypeError, ValueError):
limit = DEFAULT_LIMIT
- return path, recursive, limit if limit > 0 else 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
@staticmethod
- 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."""
+ 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."""
entries: Iterable[Path] = target_dir.rglob("*") if recursive else target_dir.iterdir()
files: list[Path] = []
+ mtimes: dict[Path, int] = {}
for entry in entries:
- if not entry.is_file(): # skip dirs, sockets, broken links, etc.
+ if sort_by == "mtime":
+ try:
+ entry_stat = entry.stat()
+ except OSError:
+ continue
+ if not S_ISREG(entry_stat.st_mode):
+ continue
+ elif 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 len(files) >= limit:
+ if sort_by == "mtime":
+ mtimes[entry] = entry_stat.st_mtime_ns
+ elif len(files) >= limit:
break
- return files
+ if sort_by == "mtime":
+ files.sort(key=lambda entry: (-mtimes[entry], entry.as_posix()))
+ return files[:limit]
@staticmethod
def _format_relative(files: list[Path], workspace_dir: Path) -> list[str]:
@@ -75,7 +105,7 @@ class ListStep(BaseStep):
async def execute(self):
assert self.context is not None
- path, recursive, limit = self._collect_params()
+ path, recursive, limit, sort_by, extensions = 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:
@@ -89,7 +119,10 @@ 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), workspace_dir)
+ items = self._format_relative(
+ self._walk_files(target_dir, recursive, limit, sort_by, extensions),
+ workspace_dir,
+ )
self.context.response.success = True
location = path or "."
diff --git a/tests/unit/test_application_config.py b/tests/unit/test_application_config.py
index df35bf89..69272d9a 100644
--- a/tests/unit/test_application_config.py
+++ b/tests/unit/test_application_config.py
@@ -17,6 +17,15 @@ from reme.steps.index._source_format import is_session_path, render_chunk_body
from reme.steps.index._watch_rules import build_watch_rules
+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))
+
+ config = ApplicationConfig(workspace_dir="~/.copaw/workspaces/default")
+
+ assert config.workspace_dir == str(tmp_path / ".copaw/workspaces/default")
+
+
def test_dialog_dir_is_not_an_application_config_field():
"""The removed option is absent from schemas and ignored when supplied."""
custom = ApplicationConfig(session_dir="sessions/", dialog_dir="somewhere/else")
diff --git a/tests/unit/test_base_agent_wrapper.py b/tests/unit/test_base_agent_wrapper.py
index 17a1b1fa..be6289bf 100644
--- a/tests/unit/test_base_agent_wrapper.py
+++ b/tests/unit/test_base_agent_wrapper.py
@@ -1,6 +1,7 @@
"""Tests for shared agent wrapper behavior."""
import sys
+from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
@@ -13,6 +14,7 @@ from reme.components.agent_wrapper import (
handle_session_command,
)
from reme.components.agent_wrapper.as_agent_wrapper import WorkspaceBackend
+from reme.components.agent_wrapper import as_agent_wrapper
from reme.components.agent_wrapper import base_agent_wrapper
from reme.components.application_context import ApplicationContext
from reme.components.outbound_proxy import FixedHttpOutboundProxy
@@ -163,3 +165,37 @@ async def test_agentscope_bash_uses_managed_proxy_without_changing_subprocess_en
await wrapper.close()
await proxy.close()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("reply_kwargs", "expected"),
+ [
+ ({}, []),
+ ({"use_builtin_tools": True}, "all"),
+ ({"builtin_tools": ["read"]}, ["read"]),
+ ],
+)
+async def test_agentscope_builtin_tools_are_opt_in(tmp_path, monkeypatch, reply_kwargs, expected):
+ """AgentScope loads no built-in tools unless a caller explicitly opts in."""
+ wrapper = AsAgentWrapper(app_context=ApplicationContext(workspace_dir=str(tmp_path)), as_llm="")
+ wrapper.as_llm = SimpleNamespace(model=object())
+ observed = {}
+
+ def builtin_tools(names, *, sequential_tool_calls=False):
+ observed["names"] = names
+ observed["sequential_tool_calls"] = sequential_tool_calls
+ return []
+
+ class FakeAgent:
+ """Minimal constructor double for AgentScope Agent."""
+
+ def __init__(self, **kwargs):
+ self.state = kwargs["state"]
+
+ monkeypatch.setattr(wrapper, "_builtin_tools", builtin_tools)
+ monkeypatch.setattr(as_agent_wrapper, "Agent", FakeAgent)
+
+ await wrapper._build_agent("hello", **reply_kwargs) # pylint: disable=protected-access
+
+ assert observed == {"names": expected, "sequential_tool_calls": True}
diff --git a/tests/unit/test_crud_steps.py b/tests/unit/test_crud_steps.py
index b08bd8b9..b0484aaf 100644
--- a/tests/unit/test_crud_steps.py
+++ b/tests/unit/test_crud_steps.py
@@ -227,6 +227,72 @@ 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"]
+
+
+def test_list_mtime_sort_skips_files_deleted_during_scan(tmp_path):
+ """A disappearing file does not fail the entire sorted listing."""
+ existing = _seed_md(tmp_path, "existing.md", "content")
+
+ class DisappearingEntry:
+ """File-like entry removed before its metadata can be read."""
+
+ suffix = ".md"
+
+ @staticmethod
+ def is_file():
+ """Match the pre-fix scan that first observed a regular file."""
+ return True
+
+ @staticmethod
+ def stat():
+ """Simulate deletion between directory enumeration and metadata lookup."""
+ raise FileNotFoundError("deleted during scan")
+
+ @staticmethod
+ def as_posix():
+ """Return a deterministic path for sorting diagnostics."""
+ return "disappearing.md"
+
+ class Directory:
+ """Directory-like source containing one stable and one vanished entry."""
+
+ @staticmethod
+ def iterdir():
+ """Yield the test entries in filesystem enumeration order."""
+ return iter((existing, DisappearingEntry()))
+
+ files = crud_list.ListStep._walk_files(
+ Directory(),
+ recursive=False,
+ limit=10,
+ sort_by="mtime",
+ extensions=frozenset({"md"}),
+ )
+
+ assert files == [existing]
+
+
# -- 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 7071ce8e..c88e4f7f 100644
--- a/tests/unit/test_workspace_web_steps.py
+++ b/tests/unit/test_workspace_web_steps.py
@@ -3,6 +3,7 @@
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
@@ -17,8 +18,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):
- super().__init__()
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
self.reply_kwargs = {}
async def reply(self, inputs, **kwargs) -> dict:
@@ -28,6 +29,7 @@ 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):
@@ -113,21 +115,56 @@ def test_save_step_rejects_external_change(tmp_path):
asyncio.run(run())
-def test_chat_step_streams_rich_chunks_and_resumes_session():
+def test_chat_step_streams_rich_chunks_and_resumes_session(tmp_path):
"""Web chat keeps StreamChunk metadata and exposes only read-only tools."""
async def run():
- agent = _StreamingAgent()
+ app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai")
+ agent = _StreamingAgent(app_context=app_context)
queue = asyncio.Queue()
context = RuntimeContext(stream_queue=queue, query="hello", session_id="session-old")
- response = await ChatStep(agent_wrapper=agent)(context)
+ response = await ChatStep(agent_wrapper=agent, app_context=app_context)(context)
- chunks = [await queue.get(), await queue.get()]
+ chunks = [await queue.get(), 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", "read", "stat", "traverse"]
+ assert agent.reply_kwargs["job_tools"] == [
+ "search",
+ "list",
+ "read",
+ "read_image",
+ "frontmatter_read",
+ "stat",
+ "traverse",
+ ]
+ assert agent.reply_kwargs["builtin_tools"] == []
+ 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
asyncio.run(run())