mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
Revert "feat(backend): improve workspace support for web clients (#417)" (#419)
Some checks are pending
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Tests ReMe / Unit Tests - py3.11 (push) Waiting to run
Tests ReMe / Unit Tests - py3.12 (push) Waiting to run
Tests ReMe / Unit Tests - py3.13 (push) Waiting to run
Windows Smoke / CLI smoke - py3.11 (push) Waiting to run
Some checks are pending
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Tests ReMe / Unit Tests - py3.11 (push) Waiting to run
Tests ReMe / Unit Tests - py3.12 (push) Waiting to run
Tests ReMe / Unit Tests - py3.13 (push) Waiting to run
Windows Smoke / CLI smoke - py3.11 (push) Waiting to run
This reverts commit b00eb0a9ea.
This commit is contained in:
parent
b00eb0a9ea
commit
f31daf1949
8 changed files with 22 additions and 259 deletions
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
"<environment_context>\n"
|
||||
f"Current date: {current.date().isoformat()}\n"
|
||||
f"Current working directory: {self.agent_wrapper.cwd.resolve(strict=False)}\n"
|
||||
"</environment_context>"
|
||||
)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 "."
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<environment_context>")
|
||||
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())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue