diff --git a/reme/components/agent_wrapper/as_agent_wrapper.py b/reme/components/agent_wrapper/as_agent_wrapper.py index 3b539bf5..7360543c 100644 --- a/reme/components/agent_wrapper/as_agent_wrapper.py +++ b/reme/components/agent_wrapper/as_agent_wrapper.py @@ -123,6 +123,86 @@ 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. @@ -192,11 +272,11 @@ class AsAgentWrapper(BaseAgentWrapper): backend = WorkspaceBackend(cwd, self.bash_environment) factories = { "bash": lambda: BypassAnalysisBash(cwd=cwd, backend=backend), - "edit": lambda: Edit(backend=backend), + "edit": lambda: WorkspaceEdit(backend=backend), "glob": lambda: Glob(backend=backend), "grep": lambda: Grep(backend=backend), - "read": lambda: Read(backend=backend), - "write": lambda: Write(backend=backend), + "read": lambda: WorkspaceRead(backend=backend), + "write": lambda: WorkspaceWrite(backend=backend), } if names is False: selected_names = [] diff --git a/reme/config/default.yaml b/reme/config/default.yaml index 30b29f1a..36036b65 100644 --- a/reme/config/default.yaml +++ b/reme/config/default.yaml @@ -1,3 +1,5 @@ +workspace_dir: ~/.copaw/workspaces/default/ + service: backend: http @@ -503,6 +505,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 00a0b286..ff1b8697 100644 --- a/reme/schema/application_config.py +++ b/reme/schema/application_config.py @@ -1,8 +1,9 @@ """Application configuration models.""" import os +from pathlib import Path -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from ..enumeration import ComponentEnum @@ -32,7 +33,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") mem_session_dir: str = Field(default="mem_session", description="Subdirectory for persisted agent sessions") @@ -53,3 +58,9 @@ 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 56f18de1..bf8002de 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", "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,7 +47,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.context.get("system_prompt") or self.DEFAULT_SYSTEM_PROMPT, + "system_prompt": self._system_prompt(), "job_tools": self.READ_ONLY_TOOLS, } if session_id := self.context.get("session_id"): @@ -34,6 +55,10 @@ 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 ebacc967..cee573ba 100644 --- a/reme/steps/file_io/list.py +++ b/reme/steps/file_io/list.py @@ -8,6 +8,8 @@ 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. @@ -36,7 +38,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 +49,37 @@ 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] = [] 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 len(files) >= limit: + if sort_by != "mtime" and len(files) >= limit: break - return files + if sort_by == "mtime": + files.sort(key=lambda entry: (-entry.stat().st_mtime, entry.as_posix())) + return files[:limit] @staticmethod def _format_relative(files: list[Path], workspace_dir: Path) -> list[str]: @@ -75,7 +94,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 +108,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_base_agent_wrapper.py b/tests/unit/test_base_agent_wrapper.py index 17a1b1fa..53fda2bb 100644 --- a/tests/unit/test_base_agent_wrapper.py +++ b/tests/unit/test_base_agent_wrapper.py @@ -139,6 +139,42 @@ 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 1132a686..0eb4d98e 100644 --- a/tests/unit/test_crud_steps.py +++ b/tests/unit/test_crud_steps.py @@ -225,6 +225,29 @@ 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 7071ce8e..ad592c2a 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,47 @@ 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", "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 asyncio.run(run())