mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
* refactor: derive dialog paths from session directory * fix: normalize configured session paths * fix: align dialog watch paths with writers * fix: reject absolute session directories
234 lines
11 KiB
Python
234 lines
11 KiB
Python
"""Read a markdown file from workspace_dir, with line-range slicing and byte-truncation."""
|
|
|
|
from pathlib import Path
|
|
|
|
from ._file_io import read_file_lines_safe, read_file_safe, truncate_session_output, truncate_text_output
|
|
from ._path import _check_path_permission, NON_MD_WARNING, gate_md, resolve_path
|
|
from ..base_step import BaseStep
|
|
from ..index._source_format import is_session_path
|
|
from ...components import R
|
|
from ...constants import DEFAULT_MAX_BYTES, MAX_FILE_READ_BYTES
|
|
from ...utils import expand_links, render_expansion_lines
|
|
|
|
|
|
@R.register("read_step")
|
|
class ReadStep(BaseStep):
|
|
"""Read a markdown file. Optional `start_line`/`end_line` for ranged reads.
|
|
|
|
Step-level attributes (``kwargs``, configured in yaml under ``steps:`` —
|
|
not exposed to LLM):
|
|
with_neighbors (bool, default False): when true and the file is
|
|
markdown, append a block listing first-order bidirectional
|
|
neighbors (out/in link targets) with name/description meta,
|
|
fetched via the file_store. Same rendering as SearchStep.
|
|
max_neighbors_per_direction (int, default 10): cap per direction.
|
|
read_step_format_session (bool, default False): when true and the
|
|
resolved path is a raw session transcript (``*.jsonl`` under the
|
|
dialog dir), render each jsonl line as
|
|
``[speaker @ created_at] content`` before truncation via
|
|
:func:`truncate_session_output`. Non-session files fall back to
|
|
the standard :func:`truncate_text_output` path. Injected at
|
|
runtime via ``injected_job_kwargs`` (takes precedence) or set in
|
|
the YAML ``steps`` config as a fallback.
|
|
|
|
Permission: honors the request-scoped ``_allowed_paths`` constraint
|
|
injected by the server into the RuntimeContext.
|
|
"""
|
|
|
|
def _fail(self, message: str, **meta) -> None:
|
|
"""Mark the response failed and stash a human-readable error."""
|
|
assert self.context is not None
|
|
self.context.response.success = False
|
|
self.context.response.answer = f"Error: {message}"
|
|
self.logger.warning(f"[{self.name}] FAILED: {message} | meta={meta}")
|
|
if meta:
|
|
self.context.response.metadata.update(meta)
|
|
|
|
def _resolve_target(self, raw: str) -> Path | None:
|
|
"""Resolve ``raw`` under workspace and gate the markdown suffix.
|
|
|
|
Non-md suffixes only warn (compatibility mode), not fail. Returns
|
|
the absolute path, or ``None`` when ``raw`` is empty/invalid.
|
|
"""
|
|
target, err = resolve_path(self.workspace_path, raw)
|
|
if err:
|
|
self._fail(err)
|
|
return None
|
|
target, is_md = gate_md(target)
|
|
if not is_md:
|
|
self.logger.info(f"[{self.name}] {NON_MD_WARNING} path={target}")
|
|
return target
|
|
|
|
def _validate_line_args(self, start_line, end_line) -> bool:
|
|
"""Accept ``None`` or any value that parses via ``int()`` (JSON/CLI often stringify)."""
|
|
for label, value in (("start_line", start_line), ("end_line", end_line)):
|
|
if value is None:
|
|
continue
|
|
try:
|
|
int(value)
|
|
except (TypeError, ValueError):
|
|
self._fail(f"{label} must be an integer, got {value!r}")
|
|
return False
|
|
return True
|
|
|
|
def _check_file(self, target: Path) -> bool:
|
|
"""Confirm ``target`` exists and is a regular file."""
|
|
if not target.exists():
|
|
self._fail(f"file {target} does not exist", path=str(target))
|
|
return False
|
|
if not target.is_file():
|
|
self._fail(f"path {target} is not a file", path=str(target))
|
|
return False
|
|
return True
|
|
|
|
def _resolve_range(self, total: int, start_line, end_line, target: Path) -> tuple[int, int] | None:
|
|
"""Normalize 1-based inclusive ``[s, e]``; reject past-EOF or inverted ranges."""
|
|
s = max(1, int(start_line) if start_line is not None else 1)
|
|
e = min(total, int(end_line) if end_line is not None else total)
|
|
if s > total:
|
|
self._fail(f"start_line {s} exceeds file length ({total} lines)", path=str(target), total_lines=total)
|
|
return None
|
|
if s > e:
|
|
self._fail(f"start_line ({s}) > end_line ({e})", path=str(target))
|
|
return None
|
|
return s, e
|
|
|
|
async def _load_content(self, target: Path) -> str | None:
|
|
"""Read via the encoding-aware helper; convert exceptions to ``_fail``."""
|
|
try:
|
|
content, _ = await read_file_safe(target)
|
|
return content
|
|
except Exception as e: # pylint: disable=broad-except
|
|
self._fail(f"read failed: {e}", path=str(target))
|
|
return None
|
|
|
|
def _truncate_with_session_format(self, excerpt: str, target: Path, start_line: int, total: int) -> str:
|
|
"""Truncate *excerpt* using session-aware rendering when *target* is a session file.
|
|
|
|
Falls back to :func:`truncate_text_output` when the path is not under
|
|
the workspace or is not a raw session transcript.
|
|
"""
|
|
try:
|
|
rel_path = target.relative_to(self.workspace_path).as_posix()
|
|
except ValueError:
|
|
self.logger.info(f"[{self.name}] skip session format: path outside workspace_path path={target}")
|
|
return truncate_text_output(excerpt, start_line=start_line, total_lines=total, file_path=str(target))
|
|
|
|
session_dir = self.config_value("session_dir")
|
|
if not is_session_path(rel_path, session_dir):
|
|
return truncate_text_output(excerpt, start_line=start_line, total_lines=total, file_path=str(target))
|
|
|
|
return truncate_session_output(
|
|
excerpt,
|
|
start_line=start_line,
|
|
total_lines=total,
|
|
file_path=str(target),
|
|
)
|
|
|
|
# pylint: disable=too-many-return-statements
|
|
async def execute(self):
|
|
assert self.context is not None
|
|
raw = str(self.context.get("path") or "")
|
|
start_line, end_line = self.context.get("start_line"), self.context.get("end_line")
|
|
with_neighbors: bool = bool(self.kwargs.get("with_neighbors", False))
|
|
max_neighbors_per_direction: int = int(self.kwargs.get("max_neighbors_per_direction", 10))
|
|
# Injected value takes precedence over YAML kwargs; check existence
|
|
# (not truthiness) so an explicit False can disable a YAML-true flag.
|
|
_format_session = self.context.get("read_step_format_session")
|
|
if _format_session is None:
|
|
_format_session = self.kwargs.get("read_step_format_session", False)
|
|
format_session: bool = bool(_format_session)
|
|
|
|
# Validate inputs and target before touching the filesystem twice.
|
|
target = self._resolve_target(raw)
|
|
if target is None:
|
|
return None
|
|
if not _check_path_permission(self.workspace_path, target, self.context.get("_allowed_paths")):
|
|
self._fail("no permission to access this file", path=str(target))
|
|
return None
|
|
if not self._validate_line_args(start_line, end_line):
|
|
return None
|
|
if not self._check_file(target):
|
|
return None
|
|
|
|
if target.stat().st_size <= MAX_FILE_READ_BYTES:
|
|
content = await self._load_content(target)
|
|
if content is None:
|
|
return None
|
|
|
|
all_lines = content.split("\n")
|
|
if content.endswith("\n"):
|
|
# split() yields a trailing empty element for the final newline;
|
|
# drop it so total matches the large-file path (line-by-line
|
|
# iteration) and the indexer (default_file_chunker), both of
|
|
# which do not count that trailing newline as its own line.
|
|
all_lines.pop()
|
|
total = len(all_lines)
|
|
bounds = self._resolve_range(total, start_line, end_line, target)
|
|
if bounds is None:
|
|
return None
|
|
s, e = bounds
|
|
excerpt = "\n".join(all_lines[s - 1 : e])
|
|
else:
|
|
s = max(1, int(start_line) if start_line is not None else 1)
|
|
requested_end = int(end_line) if end_line is not None else None
|
|
if requested_end is not None and s > requested_end:
|
|
self._fail(f"start_line ({s}) > end_line ({requested_end})", path=str(target))
|
|
return None
|
|
try:
|
|
excerpt, total, _encoding = await read_file_lines_safe(
|
|
target,
|
|
s,
|
|
requested_end,
|
|
max_collect_bytes=DEFAULT_MAX_BYTES * 2,
|
|
)
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
self._fail(f"read failed: {exc}", path=str(target))
|
|
return None
|
|
if s > total:
|
|
self._fail(f"start_line {s} exceeds file length ({total} lines)", path=str(target), total_lines=total)
|
|
return None
|
|
e = min(total, requested_end if requested_end is not None else total)
|
|
|
|
if format_session:
|
|
text = self._truncate_with_session_format(excerpt, target, s, total)
|
|
else:
|
|
text = truncate_text_output(excerpt, start_line=s, total_lines=total, file_path=str(target))
|
|
|
|
self.context.response.success = True
|
|
self.context.response.answer = text
|
|
self.logger.info(f"[{self.name}] read path={target} lines={s}-{e}/{total} bytes={len(text.encode('utf-8'))}")
|
|
|
|
if with_neighbors and target.suffix.lower() == ".md":
|
|
await self._maybe_inject_neighbors(target, text, max_neighbors_per_direction)
|
|
|
|
return self.context.response
|
|
|
|
# -- neighbor injection (opt-in) -----------------------------------------
|
|
|
|
async def _maybe_inject_neighbors(self, target: Path, text: str, max_per_direction: int) -> None:
|
|
"""Append the rendered neighbor block + stash raw expansion in metadata."""
|
|
assert self.context is not None
|
|
try:
|
|
rel_path = target.relative_to(self.workspace_path.resolve()).as_posix()
|
|
except ValueError:
|
|
self.logger.info(f"[{self.name}] skip neighbors: path outside workspace_path path={target}")
|
|
return
|
|
|
|
try:
|
|
expansion = await expand_links(self.file_store, [rel_path], max_per_direction)
|
|
except Exception as exc: # noqa: BLE001
|
|
self.logger.warning(f"[{self.name}] neighbor fetch failed: {type(exc).__name__}: {exc}")
|
|
return
|
|
|
|
per_path = expansion.get(rel_path, {})
|
|
lines = render_expansion_lines(per_path)
|
|
if not lines:
|
|
return
|
|
|
|
out_n = len(per_path.get("outlinks") or [])
|
|
in_n = len(per_path.get("inlinks") or [])
|
|
header = f"========== Related neighbors (outlinks={out_n}, inlinks={in_n}) =========="
|
|
block = "\n".join([header, *lines])
|
|
self.context.response.answer = f"{text}\n\n{block}"
|
|
self.context.response.metadata["link_expansion"] = expansion
|