mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-09 03:20:54 +00:00
* chore(benchmark): isolate dataset/workspaces/results per benchmark
- Move shared benchmark/{datasets,memory_workspaces,results} into per-benchmark subdirs benchmark/<name>/{dataset,workspaces,results}
- Update beam/longmemeval config.yaml and run.py path defaults
- Relocate longmemeval download.py to benchmark/longmemeval/ (downloads into dataset/ subdir); inline dataset download docs into README
- Update .gitignore: benchmark/*/{dataset,workspaces,results}/
- Move result-{beam,longmemeval}.md to benchmark/results_md/ and drop result- prefix; update README links
- Fix stale path refs in llm_judge.py and logs/demo_search_format.py
* feat(benchmark): add read tool to agentic answer and update BEAM results
- Add 'read' to job_tools in BaseAgenticAnswerStep for file reading capability
- Document read tool usage in lme/agentic_answer.yaml system prompt
- Update result-beam.md with latest evaluation scores (OVERALL: 0.623/0.580)
* feat(auto_memory): add source line-number markers for note traceability
- Add _format_history hook in AutoMemoryStep with line-number annotation
- Override in BeamAutoMemoryStep to prefix each turn with [Ln] for citation
- Add session_file variable to prompt templates for source marker paths
- Simplify repeated extraction rules by referencing system prompt
- Enhance agentic_answer search strategy (multi-search, read tool hint)
- Add warning log on ReadStep failure
* feat(beam): enhance auto_memory with source markers and pilot ingest tooling
* refactor(beam): rename max_chunk_words to max_segment_words, drop one-off pilot scripts
* feat: add CompressorStep and search_v2 dual-mode session compression
- Add CompressorStep (reme/steps/evolve/compressor.py) for direct LLM
text compression with optional query-guided relevance filtering
- Extend search_v2_step to support query-aware and query-independent
session transcript compression via _compress injected kwargs
- Refactor _source_format.py: split into render_chunk_entries +
join_chunk_entries; session chunks now render line-aligned with
L<n>: prefixes for verbatim/compressed parity
- Add JOB_TOOLS and INJECTED_JOB_KWARGS to BaseAgenticAnswerStep for
per-subclass tool and parameter injection
- LmeAgenticAnswerStep injects _search._compress payload to enable
query-aware compression during benchmark evaluation
- Record compression ablation results in result-longmemeval.md
- Add unit tests for CompressorStep and search compression paths
* refactor(compress): relax session compression to lenient format-preserving strategy and update LME results
* refactor(benchmark): make session compression config-driven via compress_session flag
Move session-transcript compression from LME hard-coded injection to a
runtime context flag set by evaluation.compress_session in each
benchmark config. Compression is off by default for both BEAM and LME,
and BaseAgenticAnswerStep now conditionally injects the _search compress
payload only when the flag is truthy.
* feat(lme/auto_memory): add source attribution markers with line numbers
Add _format_history to annotate each turn with [Ln] line numbers and
expose {session_file} in prompts so the agent can emit bare wikilink-style
source markers like [[session/dialog/s1.jsonl#L1-L2,L5-L6]] at the end
of factual entries. Consolidate the per-prompt body/format rules into
references to the system prompt to avoid drift, and add frontmatter-
protection guidance for the edit tool.
* feat: improve agentic answer prompt and update beam 100K results
- Strengthen abstention rule: prohibit extrapolation from related but
non-direct evidence
- Add multi-angle search after preliminary answer to check for
conflicting/supplementary/updated information
- Add max-iteration fallback to 'Information not found'
- Update beam.md with 100K results (agentscope 2.0.4.post1, from scratch)
including per-type token consumption and memory construction stats
- config.yaml: 100K dataset, 20 workers for BEAM evaluation
- run.py: add memory construction token usage tracking (default agent)
- Overall: 0.635 → 0.654 (+0.019), contradiction_resolution: 0.338 → 0.478
(+0.140), abstention: 0.500 → 0.525 (+0.025)
* feat(read): add session-aware formatting for read tool and update BEAM eval
- Add truncate_session_output in _file_io.py to render jsonl session
lines as [speaker @ time] content before byte-budget truncation
- Add read_step_format_session flag to ReadStep, honoring injected
job kwargs (precedence) and YAML fallback
- Inject read_step_format_session=True into BaseAgenticAnswerStep
so agentic answer reads render session transcripts human-readably
- Refine BEAM agentic_answer prompt: continue multi-angle search
after preliminary answer, forbid fabrication/extrapolation
- Update BEAM config to 1M variant and add sequential 100K-eval /
1M-build shell script
- Refresh benchmark/results_md/beam.md with latest results
* chore(config): disable expand_links in beam and lme search_v2 configs
* refactor(beam): drop one-off sequential 100K-eval-then-1M-build script
* fix(benchmark): add compressor job to beam config and fix BEAM clone instructions
- Add compressor job and compressor as_llm component to reme/config/beam.yaml
(aligned with lme.yaml) so that compress_session: true works for BEAM
- Add graceful degradation guard in search_v2._compress_session_entries:
when the compressor job is missing from the active config, log a warning
and skip compression instead of raising 'Job compressor not found'.
Skipped when there is no app_context so unit tests mocking run_job still
drive compression behavior.
- Fix BEAM download instructions in README.md/README_ZH.md: add mkdir -p
before cd benchmark/beam/dataset (the directory is gitignored and absent
in a fresh clone)
* fix(steps): guard compressor exceptions and fix ReadStep boolean override
1. search_v2: catch per-entry exceptions from run_job('compressor') inside
compress() so asyncio.gather never propagates a compressor failure (e.g.
temporary LLM outage). The failing entry keeps its original body while
remaining entries are still compressed, preserving already-retrieved
search results.
2. read: replace 'context_value or yaml_value' with an existence check so
that a runtime-injected False can explicitly disable a YAML-true
read_step_format_session flag.
Add focused unit tests for both paths.
* fix(search_v2): use existence check for strict_date_filter boolean override
Replace 'context_value or yaml_value' with an existence-based check so
that a runtime-injected False can explicitly disable a YAML-true
strict_date_filter flag, consistent with the read_step_format_session fix.
* refactor(search): simplify strict_date_filter fallback to truthiness-or
* style(test): rename unused param to satisfy pylint W0613
---------
Co-authored-by: sa-buc <jiangniurou.xyf@dail-algo011164204033.ET135>
258 lines
8.6 KiB
Python
258 lines
8.6 KiB
Python
"""Encoding-aware file IO, output truncation, and per-path write locks."""
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
import aiofiles
|
|
import aiofiles.os
|
|
|
|
from ...constants import DEFAULT_MAX_BYTES, MAX_FILE_READ_BYTES, TRUNCATION_NOTICE_MARKER
|
|
from ...schema import FileChunk
|
|
from ...utils import get_logger
|
|
|
|
logger = get_logger(log_to_file=False)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# In-process per-path write lock.
|
|
# ---------------------------------------------------------------------------
|
|
_PATH_LOCKS_MAX = 1024
|
|
_PATH_LOCKS: dict[str, asyncio.Lock] = {}
|
|
_PATH_LOCKS_REGISTRY = asyncio.Lock()
|
|
|
|
|
|
async def get_path_lock(target: Path) -> asyncio.Lock:
|
|
"""Return the asyncio.Lock for ``target``; created lazily on first request."""
|
|
key = str(target)
|
|
async with _PATH_LOCKS_REGISTRY:
|
|
lock = _PATH_LOCKS.get(key)
|
|
if lock is None:
|
|
if len(_PATH_LOCKS) >= _PATH_LOCKS_MAX:
|
|
to_remove = [k for k, v in _PATH_LOCKS.items() if not v.locked()]
|
|
for k in to_remove[: len(_PATH_LOCKS) // 2]:
|
|
del _PATH_LOCKS[k]
|
|
lock = asyncio.Lock()
|
|
_PATH_LOCKS[key] = lock
|
|
return lock
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Encoding detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_STANDARD_TEXT_EXTS = {
|
|
".md",
|
|
".py",
|
|
".js",
|
|
".ts",
|
|
".json",
|
|
".yaml",
|
|
".yml",
|
|
".html",
|
|
".css",
|
|
".xml",
|
|
".log",
|
|
".conf",
|
|
".ini",
|
|
".txt",
|
|
".sh",
|
|
}
|
|
_NON_STANDARD_EXTS = {".csv", ".bat", ".cmd", ".reg"}
|
|
|
|
|
|
def _try_decode(data: bytes, encodings: Iterable[str]) -> tuple[str, str] | None:
|
|
"""Return ``(text, encoding)`` for the first encoding that decodes ``data`` cleanly."""
|
|
for enc in encodings:
|
|
try:
|
|
return data.decode(enc), enc
|
|
except (UnicodeDecodeError, LookupError):
|
|
continue
|
|
return None
|
|
|
|
|
|
def _decode_known_file(data: bytes, file_extension: str) -> tuple[str, str]:
|
|
"""Decode file bytes using the extension as a hint. Returns ``(text, encoding)``."""
|
|
if data.startswith(b"\xef\xbb\xbf"):
|
|
return data.decode("utf-8-sig"), "utf-8-sig"
|
|
if data.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
try:
|
|
return data.decode("utf-16"), "utf-16"
|
|
except UnicodeDecodeError:
|
|
pass
|
|
|
|
ext = (file_extension or "").lower()
|
|
|
|
if ext in _STANDARD_TEXT_EXTS:
|
|
try:
|
|
return data.decode("utf-8-sig"), "utf-8"
|
|
except UnicodeDecodeError:
|
|
pass
|
|
|
|
if ext in _NON_STANDARD_EXTS:
|
|
result = _try_decode(data, ("utf-8-sig", "gbk"))
|
|
if result is not None:
|
|
text, enc = result
|
|
return text, "utf-8" if enc == "utf-8-sig" else enc
|
|
|
|
return data.decode("utf-8", errors="replace"), "utf-8"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# File read / write
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def read_file_safe(file_path, max_bytes: int = MAX_FILE_READ_BYTES) -> tuple[str, str]:
|
|
"""Read file in byte mode and decode using extension-aware strategy.
|
|
|
|
Returns ``(text, encoding)``.
|
|
"""
|
|
stat = await aiofiles.os.stat(str(file_path))
|
|
read_size = min(stat.st_size, max_bytes)
|
|
async with aiofiles.open(str(file_path), "rb") as f:
|
|
data = await f.read(read_size)
|
|
return _decode_known_file(data, Path(file_path).suffix)
|
|
|
|
|
|
async def read_file_lines_safe(
|
|
file_path,
|
|
start_line: int,
|
|
end_line: int | None,
|
|
*,
|
|
max_collect_bytes: int = DEFAULT_MAX_BYTES * 2,
|
|
) -> tuple[str, int, str]:
|
|
"""Read a 1-based inclusive line range without loading the full file.
|
|
|
|
Returns ``(text, total_lines, encoding)``.
|
|
"""
|
|
encoding = await detect_file_encoding(file_path)
|
|
lines: list[str] = []
|
|
collected_bytes = 0
|
|
total = 0
|
|
async with aiofiles.open(str(file_path), "r", encoding=encoding, errors="replace") as f:
|
|
async for line in f:
|
|
total += 1
|
|
if total >= start_line and (end_line is None or total <= end_line):
|
|
if collected_bytes < max_collect_bytes:
|
|
cleaned = line.rstrip("\n")
|
|
lines.append(cleaned)
|
|
collected_bytes += len(cleaned.encode(encoding, errors="replace")) + 1
|
|
return "\n".join(lines), total, encoding
|
|
|
|
|
|
async def detect_file_encoding(file_path, sniff_bytes: int = 8192) -> str:
|
|
"""Detect the encoding of an existing file so writes can preserve it."""
|
|
try:
|
|
async with aiofiles.open(str(file_path), "rb") as f:
|
|
data = await f.read(sniff_bytes)
|
|
except Exception:
|
|
return "utf-8"
|
|
_, enc = _decode_known_file(data, Path(file_path).suffix)
|
|
return enc
|
|
|
|
|
|
async def write_file_safe(file_path: Path, content: str | bytes, encoding: str = "utf-8") -> None:
|
|
"""Write ``content`` to ``file_path`` in binary mode; creates parent dirs."""
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if isinstance(content, str):
|
|
try:
|
|
payload = content.encode(encoding)
|
|
except (UnicodeEncodeError, LookupError):
|
|
logger.warning(
|
|
"write_file_safe: %r cannot encode all chars, falling back to utf-8",
|
|
encoding,
|
|
)
|
|
payload = content.encode("utf-8")
|
|
else:
|
|
payload = content
|
|
async with aiofiles.open(str(file_path), "wb") as f:
|
|
await f.write(payload)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Output truncation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def truncate_text_output(
|
|
text: str,
|
|
*,
|
|
start_line: int = 1,
|
|
total_lines: int = 0,
|
|
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
file_path: str | None = None,
|
|
encoding: str = "utf-8",
|
|
) -> str:
|
|
"""Truncate text by bytes preserving line integrity; append a continuation notice."""
|
|
if not text or max_bytes <= 0:
|
|
return text
|
|
|
|
try:
|
|
text_bytes = text.encode(encoding)
|
|
if len(text_bytes) <= max_bytes:
|
|
return text
|
|
|
|
truncated = text_bytes[:max_bytes]
|
|
result = truncated.decode(encoding, errors="ignore")
|
|
newline_count = result.count("\n")
|
|
next_line = start_line + max(1, newline_count)
|
|
|
|
if next_line <= total_lines:
|
|
read_from = next_line
|
|
elif start_line < total_lines:
|
|
read_from = total_lines
|
|
else:
|
|
return result
|
|
|
|
notice = (
|
|
TRUNCATION_NOTICE_MARKER + f"\nThe output above was truncated."
|
|
f"\nThe full content is saved to the file and contains {total_lines} lines in total."
|
|
f"\nThis excerpt starts at line {start_line} and covers the next {max_bytes} bytes."
|
|
f"\nIf the current content is not enough, call `read` with file={file_path or ''} "
|
|
f"start_line={read_from} to read more."
|
|
)
|
|
return result + notice
|
|
except Exception:
|
|
logger.warning("truncate_text_output failed, returning original text", exc_info=True)
|
|
return text
|
|
|
|
|
|
def truncate_session_output(
|
|
text: str,
|
|
*,
|
|
start_line: int = 1,
|
|
total_lines: int = 0,
|
|
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
file_path: str | None = None,
|
|
encoding: str = "utf-8",
|
|
) -> str:
|
|
"""Render session jsonl lines then truncate by bytes preserving line integrity.
|
|
|
|
Each line of *text* is expected to be a serialized ``Msg`` JSON from a raw
|
|
session transcript (``*.jsonl``). Lines are first rendered via
|
|
:func:`~reme.steps.index._source_format.render_session_chunk_lines` — one
|
|
message per line as ``[speaker @ created_at] content`` with internal
|
|
newlines flattened — and then truncated with the same byte-budget logic
|
|
as :func:`truncate_text_output`.
|
|
"""
|
|
if not text or max_bytes <= 0:
|
|
return text
|
|
|
|
# Render each jsonl line into a human-readable [speaker @ time] content line.
|
|
# Import locally to avoid a hard dependency from file_io on the index package
|
|
# at module load time; the import is safe (no circular dependency).
|
|
from ..index._source_format import render_session_chunk_lines
|
|
|
|
chunk = FileChunk(text=text)
|
|
rendered_lines = render_session_chunk_lines(chunk)
|
|
rendered_text = "\n".join(rendered_lines)
|
|
|
|
# Reuse the same byte-budget truncation as truncate_text_output.
|
|
return truncate_text_output(
|
|
rendered_text,
|
|
start_line=start_line,
|
|
total_lines=total_lines,
|
|
max_bytes=max_bytes,
|
|
file_path=file_path,
|
|
encoding=encoding,
|
|
)
|