feat(beam): enhance auto_memory with source markers and pilot ingest tooling

This commit is contained in:
sa-buc 2026-07-28 11:40:20 +08:00
parent ed89e59ead
commit db7f718236
7 changed files with 501 additions and 27 deletions

View file

@ -0,0 +1,65 @@
"""Pilot: ingest a few BEAM sessions and inspect source-marker quality.
Temporary helper for prompt tuning — ingests the first N sessions of one
case into an isolated pilot workspace (never the real benchmark one).
Usage:
python benchmark/beam/_pilot_ingest.py [case_id] [n_sessions]
"""
import asyncio
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from run import _PROJECT_ROOT, load_beam_chat, load_eval_config # noqa: E402
PILOT_ROOT = _PROJECT_ROOT / "benchmark/memory_workspaces/beam_pilot"
async def main(case_id: str, n_sessions: int):
from reme import Application
from reme.config import resolve_app_config
eval_config = load_eval_config()
chat_size = eval_config["dataset"]["chat_size"]
beam_root = _PROJECT_ROOT / eval_config["dataset"].get("beam_root", "benchmark/datasets/BEAM")
chat_path = beam_root / "chats" / chat_size / case_id / "chat.json"
case_dir = PILOT_ROOT / f"{chat_size}_{case_id}"
if case_dir.exists():
shutil.rmtree(case_dir)
case_dir.mkdir(parents=True, exist_ok=True)
cfg = resolve_app_config(
config=eval_config["reme"]["config"],
workspace_dir=str(case_dir / ".reme"),
log_to_console=False,
log_to_file=False,
enable_logo=False,
)
app = Application(**cfg)
await app.start()
try:
sessions = load_beam_chat(chat_path, chat_size, case_id)[:n_sessions]
for i, session in enumerate(sessions):
print(f"[pilot] ingesting session {i + 1}/{len(sessions)}: "
f"{session['session_id']} msgs={len(session['messages'])}", flush=True)
resp = await app.run_job(
"auto_memory",
messages=session["messages"],
session_id=session["session_id"],
date=session["date"],
)
print(f"[pilot] success={resp.success} chunks={resp.metadata.get('chunks')} "
f"answer={str(resp.answer)[:300]}", flush=True)
await app.run_job("index_update")
finally:
await app.close()
if __name__ == "__main__":
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "1",
int(sys.argv[2]) if len(sys.argv) > 2 else 2))

View file

@ -0,0 +1,128 @@
"""Check source markers in BEAM auto-memory notes.
Scans daily notes under one or more case workspaces and validates every
source marker against the required format::
[[<session path>#L<start>-L<end>(,L<start>-L<end>)*]]
Reported problems:
- ``legacy_format``: old single-bracket ``[path:1-2]`` markers.
- ``bad_anchor``: ``[[...jsonl#...]]`` whose anchor is not L-prefixed
hyphen ranges (colon separators, missing ``L``, spaces, bare line...).
- ``labelled``: marker prefixed with ``Source``/``来源`` labels.
- ``missing_file``: linked session file does not exist in the workspace.
- ``out_of_range``: cited line numbers exceed the session file length,
or start > end.
Usage:
python benchmark/beam/check_source_markers.py [workspace_root ...]
(default: benchmark/memory_workspaces/beam)
"""
import re
import sys
from pathlib import Path
_PROJECT_ROOT = Path(__file__).parent.parent.parent
WIKILINK_RE = re.compile(r"\[\[([^\[\]\|\n]+?)\]\]")
VALID_ANCHOR_RE = re.compile(r"^L(\d+)-L(\d+)(,L(\d+)-L(\d+))*$")
RANGE_RE = re.compile(r"L(\d+)-L(\d+)")
# Old format: [session/dialog/x.jsonl:1-2,5-6] (single brackets, colon)
LEGACY_RE = re.compile(r"(?<!\[)\[([^\[\]\n]*\.jsonl:[^\[\]\n]*)\](?!\])")
LABEL_RE = re.compile(r"(?:Source|source|来源)[::]?\s*(\[\[[^\[\]\n]*\.jsonl[^\[\]\n]*\]\])")
def _split_frontmatter(text: str) -> str:
"""Return the note body, skipping the leading YAML frontmatter block."""
if text.startswith("---\n"):
end = text.find("\n---", 4)
if end != -1:
return text[end + 4 :]
return text
def _session_line_count(workspace: Path, rel_path: str) -> int | None:
f = workspace / rel_path
if not f.is_file():
return None
with open(f, encoding="utf-8") as fh:
return sum(1 for _ in fh)
def check_note(note: Path, workspace: Path) -> tuple[list[dict], int]:
"""Return (problems, valid_marker_count) for one note file."""
text = _split_frontmatter(note.read_text(encoding="utf-8"))
rel_note = str(note.relative_to(workspace))
problems: list[dict] = []
valid = 0
def _add(kind: str, marker: str):
problems.append({"note": rel_note, "kind": kind, "marker": marker.strip()[:160]})
for m in LEGACY_RE.finditer(text):
_add("legacy_format", m.group(0))
for m in LABEL_RE.finditer(text):
_add("labelled", m.group(0))
line_counts: dict[str, int | None] = {}
for m in WIKILINK_RE.finditer(text):
inner = m.group(1)
if "#" not in inner:
continue # plain file link (e.g. day-index), not a line marker
target, anchor = inner.split("#", 1)
if not target.endswith(".jsonl"):
continue
if not VALID_ANCHOR_RE.match(anchor):
_add("bad_anchor", m.group(0))
continue
if target not in line_counts:
line_counts[target] = _session_line_count(workspace, target)
n_lines = line_counts[target]
if n_lines is None:
_add("missing_file", m.group(0))
continue
ok = True
for r in RANGE_RE.finditer(anchor):
start, end = int(r.group(1)), int(r.group(2))
if start < 1 or end > n_lines or start > end:
_add("out_of_range", f"{m.group(0)} (file has {n_lines} lines)")
ok = False
break
if ok:
valid += 1
return problems, valid
def main(roots: list[str]) -> int:
"""Scan every case workspace under *roots* and print a problem report."""
total_valid = 0
all_problems: list[dict] = []
n_notes = 0
for root in roots:
root_path = Path(root)
if not root_path.is_absolute():
root_path = _PROJECT_ROOT / root
for workspace in sorted(root_path.glob("*/.reme")):
for note in sorted(workspace.rglob("daily/*/*.md")):
n_notes += 1
problems, valid = check_note(note, workspace)
total_valid += valid
all_problems.extend({**p, "workspace": workspace.parent.name} for p in problems)
print(f"Scanned {n_notes} notes: {total_valid} valid markers, {len(all_problems)} problems")
by_kind: dict[str, int] = {}
for p in all_problems:
by_kind[p["kind"]] = by_kind.get(p["kind"], 0) + 1
for kind, count in sorted(by_kind.items(), key=lambda kv: -kv[1]):
print(f" {kind}: {count}")
print()
for p in all_problems:
print(f"[{p['kind']}] {p['workspace']}/{p['note']}\n {p['marker']}")
return 1 if all_problems else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:] or ["benchmark/memory_workspaces/beam"]))

View file

@ -33,4 +33,22 @@
| preference_following | 0.718 | 0.676 | 0.805 | 0.796 |
| summarization | 0.516 | 0.303 | 0.672 | 0.504 |
| temporal_reasoning | 0.198 | 0.169 | 0.462 | 0.448 |
| **OVERALL** | **0.462** | **0.396** | **0.623** | **0.580** |
| **OVERALL** | **0.462** | **0.396** | **0.623** | **0.580** |
在auto-memory中加入Source Index机制
### 1M (2026-07-28, 35 cases / 700 Qs)
| 题型 | Agentic | Agentic Binary |
|---|---|---|
| abstention | 0.457 | 0.457 |
| contradiction_resolution | 0.405 | 0.371 |
| event_ordering | 0.566 | 0.478 |
| information_extraction | 0.782 | 0.742 |
| instruction_following | 0.860 | 0.843 |
| knowledge_update | 0.704 | 0.679 |
| multi_session_reasoning | 0.663 | 0.603 |
| preference_following | 0.827 | 0.807 |
| summarization | 0.686 | 0.532 |
| temporal_reasoning | 0.511 | 0.500 |
| **OVERALL** | **0.646** | **0.601** |

View file

@ -164,6 +164,10 @@ jobs:
- messages
steps:
- backend: beam_auto_memory_step
# Long sessions are split into turn-aligned chunks and fed to the
# agent incrementally; each chunk holds at most this many words.
# <= 0 disables splitting.
max_chunk_words: 10000
# ── Reindex (full rebuild) ──
reindex:

View file

@ -5,8 +5,61 @@ from datetime import datetime, timedelta
from agentscope.message import Msg
from ...evolve.auto_memory import AutoMemoryStep, _normalize_msg_timestamp
from ...file_io import validate_session_id
from ....components import R
# Runtime-context key carrying the 0-based line offset of the current chunk
# inside the full session file (chunked ingestion of long sessions).
_LINE_OFFSET_KEY = "beam_line_offset"
def _msg_word_count(msg: Msg) -> int:
"""Whitespace-separated word count of a message's text content."""
return len((msg.get_text_content() or "").split())
def split_turn_chunks(messages: list[Msg], max_words: int) -> list[tuple[int, list[Msg]]]:
"""Split *messages* into chunks of complete turns bounded by *max_words*.
A turn starts at every ``user`` message and spans all following non-user
messages (assistant replies). Chunks only break at turn boundaries, so a
``user + assistant`` exchange is never split across two chunks. A single
turn larger than *max_words* still becomes its own (oversized) chunk.
Returns ``[(offset, msgs), ...]`` where ``offset`` is the 0-based index of
the chunk's first message in *messages* — i.e. its line number in the
session file minus 1. ``max_words <= 0`` disables splitting.
"""
if not messages:
return []
if max_words <= 0:
return [(0, list(messages))]
# Group into turns: each user message opens a new turn.
turns: list[list[Msg]] = []
for msg in messages:
if msg.role == "user" or not turns:
turns.append([msg])
else:
turns[-1].append(msg)
chunks: list[tuple[int, list[Msg]]] = []
current: list[Msg] = []
current_words = 0
offset = 0
for turn in turns:
turn_words = sum(_msg_word_count(m) for m in turn)
if current and current_words + turn_words > max_words:
chunks.append((offset, current))
offset += len(current)
current = []
current_words = 0
current.extend(turn)
current_words += turn_words
if current:
chunks.append((offset, current))
return chunks
def _parse_iso_seconds(value: str) -> datetime | None:
"""Parse an ISO-8601 timestamp that is precise to at least seconds.
@ -121,8 +174,75 @@ class BeamAutoMemoryStep(AutoMemoryStep):
Date pinning for ``daily_write`` is handled by the base class through the
agent wrapper's server-owned ``injected_job_kwargs``.
Long sessions are ingested incrementally: the message list is split into
chunks of complete turns (``max_chunk_words`` step config, default 10000
words per chunk) and each chunk runs one agent pass — the first pass
creates the daily note, later passes merge into it. Line numbers shown to
the agent always refer to the ORIGINAL session file, not the chunk.
"""
async def execute(self):
assert self.context is not None
raw_messages = self.context.get("messages") or []
session_id: str = self.context.get("session_id", "")
max_words = int(self.kwargs.get("max_chunk_words", 10000) or 0)
messages = self._build_messages(raw_messages)
chunks = split_turn_chunks(messages, max_words)
if len(chunks) <= 1:
self.context[_LINE_OFFSET_KEY] = 0
await super().execute()
return
# Persist the FULL session file up-front so every chunk's [Ln] labels
# (and the note's source markers) match the final on-disk layout. The
# per-chunk saves inside the base execute become no-op appends.
if session_id and validate_session_id(session_id) is None:
await self._save_session_messages(session_id, messages)
base_hint = self.context.get("memory_hint", "") or ""
answers: list[str] = []
failed_chunks = 0
self.logger.info(
f"[{self.name}] chunked ingestion session_id={session_id!r} "
f"messages={len(messages)} chunks={len(chunks)} max_chunk_words={max_words}",
)
for part, (offset, chunk) in enumerate(chunks, start=1):
part_hint = (
f"This is part {part}/{len(chunks)} of one long session "
f"(lines {offset + 1}-{offset + len(chunk)} of the session file). "
"Earlier parts were already recorded; extract ONLY from the turns shown below."
)
self.context["messages"] = chunk
self.context["memory_hint"] = f"{base_hint}\n{part_hint}".strip()
self.context[_LINE_OFFSET_KEY] = offset
# One bad chunk (e.g. transiently corrupted note frontmatter) must
# not lose the remaining chunks of the session.
try:
await super().execute()
ok = bool(self.context.response.success)
summary = self.context.response.answer or ""
except Exception as exc: # pylint: disable=broad-exception-caught
ok = False
summary = f"Error: {exc}"
self.logger.warning(
f"[{self.name}] chunk {part}/{len(chunks)} failed session_id={session_id!r}: {exc}",
)
if not ok:
failed_chunks += 1
answers.append(f"[part {part}/{len(chunks)}] {summary}")
# Restore caller-visible context and aggregate the response.
self.context["messages"] = raw_messages
self.context["memory_hint"] = base_hint
self.context.response.success = failed_chunks < len(chunks)
self.context.response.answer = "\n".join(answers)
self.context.response.metadata.update(
{"n_messages": len(messages), "chunks": len(chunks), "failed_chunks": failed_chunks},
)
def _build_messages(self, raw_messages: list) -> list[Msg]:
# Interpolate timestamps: if any message carries created_at, fill in
# the rest via linear interpolation so the whole session has coherent
@ -135,15 +255,18 @@ class BeamAutoMemoryStep(AutoMemoryStep):
def _format_history(self, messages: list[Msg]) -> str:
# Annotate every turn with its physical line number in the session
# file so the agent can cite information sources as
# [<session file path>:<start line>-<end line>]. A BEAM batch is already
# [[<session file path>#L<start>-L<end>]]. A BEAM batch is already
# in chronological order, matching the one-message-per-line layout
# written by AutoMemoryStep._save_session_messages, so number each turn
# by its position (no re-sorting).
# by its position plus the chunk's offset into the full session file
# (no re-sorting). When the session is ingested in chunks, the offset
# keeps [Ln] labels aligned with the ORIGINAL file, not the chunk.
session_id = self.context.get("session_id", "") if self.context is not None else ""
line_offset = int(self.context.get(_LINE_OFFSET_KEY, 0) or 0) if self.context is not None else 0
source_path = self._session_source_path(session_id)
turns: list[str] = []
for line, msg in enumerate(messages, start=1):
for line, msg in enumerate(messages, start=line_offset + 1):
text = (msg.get_text_content() or "").strip()
if not text:
continue
@ -153,9 +276,13 @@ class BeamAutoMemoryStep(AutoMemoryStep):
if not turns:
return "(empty)"
first_line = line_offset + 1
last_line = line_offset + len(messages)
preamble = (
f"Source file: {source_path}\n"
"(Each turn below is prefixed with [Ln] = its line number in the source file.)"
f"(Excerpt covering lines {first_line}-{last_line} of the source file. Each turn below is "
"prefixed with [Ln] = its line number in the source file; copy these numbers verbatim "
"into source markers.)"
)
return preamble + "\n\n" + "\n\n".join(turns)

View file

@ -7,12 +7,13 @@ system_prompt: |
2. **Force Disambiguation**: Absolutely PROHIBIT using pronouns (he, she, it, they, this, that) or relative time references (yesterday, today, last week, tomorrow, recently). Always use full names, explicit dates, and specific references.
3. **Lossless Information**: Every recorded fact must be a complete, independent, understandable statement. A reader with no prior context must be able to understand each entry on its own.
4. **Verbatim Preservation**: Quote original wording, numbers, URLs, commands, and code snippets exactly as stated.
5. **Source Attribution**: Mark the source of factual content using the `Source file` path (copied verbatim) and the `[Ln]` line numbers of the turns the fact was derived from, joining the start and end line with a hyphen `-` (NOT a colon), e.g. `[session/dialog/beam_1M_1_batch1.jsonl:1-2]`.
- **No `Source` label**: write the bracket marker DIRECTLY at the end of the entry (or heading). Do NOT prefix it with `Source`, `Source:`, `source`, `来源`, or any other word — output only the bare `[...]` marker.
- **Full path, NO `L` prefix**: EVERY marker MUST begin with the full session file path, then a colon, then BARE line numbers — e.g. `[session/dialog/beam_1M_2_batch1.jsonl:29-34,53-58]`. The `[Ln]` labels prefixing each input turn (e.g. `[L29 | user @ ...]`) are INPUT annotations only; NEVER copy that `L` into your marker and NEVER drop the file path. A marker that omits the `.jsonl:` path, or writes numbers with an `L` prefix, or uses a colon instead of a hyphen inside a range, is INVALID. ✗ WRONG: `[L29-34, L53-58]`, `[29-34]`, `[L1:L2]`, `Source: [L29-34]`. ✓ RIGHT: `[session/dialog/beam_1M_2_batch1.jsonl:29-34,53-58]`. Each line number equals the message index in the conversation (one message = one line in the source file); newlines inside a message's own content do NOT affect its line number.
- **Merge same-file ranges into ONE marker**: instead of back-to-back markers like `[...jsonl:1-2][...jsonl:5-6]`, write a single marker with comma-separated ranges `[...jsonl:1-2,5-6]`.
5. **Source Attribution**: Mark the source of factual content with a wikilink-style marker built from the `Source file` path (copied verbatim) and the `[Ln]` line numbers of the turns the fact was derived from. The EXACT format is `[[<path>#L<start>-L<end>(,L<start>-L<end>)*]]`, e.g. `[[session/dialog/beam_1M_1_batch1.jsonl#L1-L2]]`.
- **No `Source` label**: write the marker DIRECTLY at the end of the entry (or heading). Do NOT prefix it with `Source`, `Source:`, `source`, `来源`, or any other word — output only the bare `[[...]]` marker.
- **Double brackets + `#` + `L`-prefixed hyphen ranges**: EVERY marker MUST be wrapped in DOUBLE square brackets `[[...]]`, begin with the full session file path, then a hash `#`, then one or more line ranges. Each range is `L<start>-L<end>` — BOTH endpoints carry the `L` prefix and are joined by a hyphen `-` (NEVER a colon). A single line is written as a one-line range, e.g. `L41-L41`. This applies to EVERY comma-separated range too: `L69-L72,L77` is INVALID — write `L69-L72,L77-L77`. Multiple ranges of the same file go into ONE marker, separated by commas with NO spaces. ✗ WRONG: `[session/dialog/....jsonl:29-34]` (old single-bracket colon format), `[[...jsonl#29-34]]` (numbers missing `L`), `[[...jsonl#L29-34]]` (end missing `L`), `[[...jsonl#L29:L34]]` (colon instead of hyphen), `[[L29-L34]]` (path dropped), `[[...jsonl#L41]]` (bare single line — write `L41-L41`), `[[...jsonl#L69-L72,L77]]` (second range is a bare line — write `L77-L77`), `Source: [[...jsonl#L29-L34]]` (label added). ✓ RIGHT: `[[session/dialog/beam_1M_2_batch1.jsonl#L29-L34,L53-L58]]`.
- **Line numbers come from the `[Ln]` labels**: the `[Ln]` label prefixing each input turn (e.g. `[L29 | user @ ...]`) is that message's line number in the ORIGINAL source file — copy those numbers verbatim. The excerpt you see may START at a line far greater than 1; NEVER renumber from 1 and NEVER cite line numbers outside the excerpt's stated range. Newlines inside a message's own content do NOT affect its line number.
- **Merge same-file ranges into ONE marker**: instead of back-to-back markers like `[[...jsonl#L1-L2]][[...jsonl#L5-L6]]`, write a single marker with comma-separated ranges `[[...jsonl#L1-L2,L5-L6]]`.
- **Hoist shared markers, never repeat**: when several consecutive bullets share the SAME marker, do NOT append it to every bullet. Attach ONE marker to the group instead — either append it to the section heading (where it then covers every bullet beneath that heading until a different marker appears), or merge those bullets into a single entry carrying one trailing marker. Repeating an identical marker across sibling bullets is PROHIBITED — it wastes tokens.
- **Cite enough self-contained context**: the cited `[Ln]` range must be self-contained. Do NOT cite only the single line that states the key detail; widen the range to include the surrounding turns (e.g. the question or setup turn that gives the fact its meaning) so that a reader who opens only that line range understands the fact correctly and is not misled by missing context.
- **Cite enough self-contained context**: the cited line range must be self-contained. Do NOT cite only the single line that states the key detail; widen the range to include the surrounding turns (e.g. the question or setup turn that gives the fact its meaning) so that a reader who opens only that line range understands the fact correctly and is not misled by missing context.
## What to Extract
@ -28,7 +29,7 @@ system_prompt: |
## Body Format
Use structured sections with headings. Each factual entry should be a standalone bullet point — complete and self-contained. Group entries by topic. Attach source markers coarsely (see the Source Attribution principle): prefer ONE marker per section heading or per grouped entry, write only the bare `[...]` marker with no `Source` label, and never repeat an identical marker across consecutive bullets. Do NOT add a `## Metadata` section.
Use structured sections with headings. Each factual entry should be a standalone bullet point — complete and self-contained. Group entries by topic. Attach source markers coarsely (see the Source Attribution principle): prefer ONE marker per section heading or per grouped entry, write only the bare `[[...]]` marker with no `Source` label, and never repeat an identical marker across consecutive bullets. Do NOT add a `## Metadata` section.
## Frontmatter Rules
@ -53,12 +54,13 @@ system_prompt_zh: |
2. **强制消歧**:绝对禁止使用代词(他、她、它、他们、这个、那个)或相对时间(昨天、今天、上周、明天、最近)。必须使用全名、明确日期和具体引用。
3. **无损信息**:每条记录的事实必须是完整的、独立的、可理解的陈述。没有上下文的读者必须能单独理解每条记录。
4. **原文保留**:逐字引用原始措辞、数字、URL、命令和代码片段。
5. **来源标注**:为事实内容标注来源,使用对话中显示的 `Source file` 路径(逐字复制)与该事实所依据对话轮次的 `[Ln]` 行号,起始行与结束行之间用连字符 `-` 连接(不是冒号 `:`),例如 `[session/dialog/beam_1M_1_batch1.jsonl:1-2]`。
- **不要 `Source` 标签**:把方括号标记**直接**写在条目(或标题)末尾。不要在前面加 `Source`、`Source:`、`source`、`来源` 或任何其他词——只输出裸的 `[...]` 标记。
- **完整路径、禁止 `L` 前缀**:每个标记都**必须**以完整的 session 文件路径开头,然后接冒号,再接**纯数字**行号——例如 `[session/dialog/beam_1M_2_batch1.jsonl:29-34,53-58]`。输入中每条对话轮次前面的 `[Ln]` 标签(如 `[L29 | user @ ...]`)**仅是输入标注**;绝不能把那个 `L` 抄进你的标记,也绝不能丢掉文件路径。凡是省略 `.jsonl:` 路径、或数字带 `L` 前缀、或在区间内用冒号而非连字符的标记,都是**无效的**。✗ 错误:`[L29-34, L53-58]`、`[29-34]`、`[L1:L2]`、`Source: [L29-34]`。✓ 正确:`[session/dialog/beam_1M_2_batch1.jsonl:29-34,53-58]`。每个行号就是该消息在对话中的序号(一条消息对应源文件中的一行);消息正文内部的换行不影响其行号。
- **同一文件多区间合并进一个标记**:不要写成相邻的多个标记 `[...jsonl:1-2][...jsonl:5-6]`,而要写成用逗号分隔区间的单个标记 `[...jsonl:1-2,5-6]`。
5. **来源标注**:为事实内容标注来源,使用 wikilink 风格的标记,由对话中显示的 `Source file` 路径(逐字复制)与该事实所依据对话轮次的 `[Ln]` 行号拼成。格式**必须**为 `[[<path>#L<start>-L<end>(,L<start>-L<end>)*]]`,例如 `[[session/dialog/beam_1M_1_batch1.jsonl#L1-L2]]`。
- **不要 `Source` 标签**:把标记**直接**写在条目(或标题)末尾。不要在前面加 `Source`、`Source:`、`source`、`来源` 或任何其他词——只输出裸的 `[[...]]` 标记。
- **双层方括号 + `#` + 带 `L` 前缀的连字符区间**:每个标记都**必须**用双层方括号 `[[...]]` 包裹,以完整的 session 文件路径开头,然后接 `#`,再接一个或多个行号区间。每个区间写作 `L<start>-L<end>`——两端都带 `L` 前缀,用连字符 `-` 连接(**绝不**用冒号)。单行引用写成单行区间,如 `L41-L41`;逗号分隔的每一个区间也同样适用:`L69-L72,L77` 是**无效的**,应写成 `L69-L72,L77-L77`。同一文件的多个区间放入**同一个**标记,用逗号分隔且不加空格。✗ 错误:`[session/dialog/....jsonl:29-34]`(旧的单括号冒号格式)、`[[...jsonl#29-34]]`(数字缺 `L`)、`[[...jsonl#L29-34]]`(末端缺 `L`)、`[[...jsonl#L29:L34]]`(用了冒号)、`[[L29-L34]]`(丢了路径)、`[[...jsonl#L41]]`(裸单行,应写 `L41-L41`)、`[[...jsonl#L69-L72,L77]]`(第二个区间是裸单行,应写 `L77-L77`)、`Source: [[...jsonl#L29-L34]]`(加了标签)。✓ 正确:`[[session/dialog/beam_1M_2_batch1.jsonl#L29-L34,L53-L58]]`。
- **行号来自 `[Ln]` 标签**:输入中每条对话轮次前的 `[Ln]` 标签(如 `[L29 | user @ ...]`)就是该消息在**原始**源文件中的行号——请逐字复制这些数字。你看到的片段可能从远大于 1 的行号开始;**绝不**要从 1 重新编号,也**绝不**要引用片段声明范围之外的行号。消息正文内部的换行不影响其行号。
- **同一文件多区间合并进一个标记**:不要写成相邻的多个标记 `[[...jsonl#L1-L2]][[...jsonl#L5-L6]]`,而要写成用逗号分隔区间的单个标记 `[[...jsonl#L1-L2,L5-L6]]`。
- **上提共享标记,绝不重复**:当连续多个条目共享**相同**标记时,不要给每个条目都追加。而是给整组只标注一个标记——要么把标记追加到分节标题上(此时它覆盖该标题下、直到出现不同标记之前的所有条目),要么把这些条目合并为一条并只在末尾带一个标记。在同级条目上重复相同标记是**被禁止的**——会浪费 token。
- **引用充分且自洽的上下文**:所标注的 `[Ln]` 区间必须自洽完整。不要只标注陈述关键信息的那一句,要把区间扩大到包含周边轮次(例如赋予该事实意义的提问或铺垫轮次),使得仅打开该行号区间的读者也能正确理解这条事实,不会因缺失上下文而断章取义。
- **引用充分且自洽的上下文**:所标注的行号区间必须自洽完整。不要只标注陈述关键信息的那一句,要把区间扩大到包含周边轮次(例如赋予该事实意义的提问或铺垫轮次),使得仅打开该行号区间的读者也能正确理解这条事实,不会因缺失上下文而断章取义。
## 提取什么
@ -74,7 +76,7 @@ system_prompt_zh: |
## 正文格式
使用带标题的结构化分节。每条事实记录应是独立的列表项——完整且自包含。按主题分组。粗粒度地标注来源(见来源标注原则):优先每个分节标题或每组条目只用一个标记,只写裸的 `[...]` 标记且不带 `Source` 标签,并且绝不在连续条目上重复相同标记。不要添加 `## Metadata` 部分。
使用带标题的结构化分节。每条事实记录应是独立的列表项——完整且自包含。按主题分组。粗粒度地标注来源(见来源标注原则):优先每个分节标题或每组条目只用一个标记,只写裸的 `[[...]]` 标记且不带 `Source` 标签,并且绝不在连续条目上重复相同标记。不要添加 `## Metadata` 部分。
## Frontmatter 规则
@ -94,7 +96,7 @@ user_message_create: |
Today: {today}
Extra hint: {note}
Session ID: {session_id}
Session file: {session_file} ← copy THIS exact path into every source marker (e.g. `[{session_file}:1-2,5-6]`)
Session file: {session_file} ← copy THIS exact path into every source marker (e.g. `[[{session_file}#L1-L2,L5-L6]]`)
# Recent Conversation
@ -130,7 +132,7 @@ user_message_create: |
⚠️ CRITICAL: You MUST invoke `daily_write` as a **tool call** (function call). Do NOT write `daily_write name=...` as plain text — that will NOT execute the tool and NO memory will be saved.
Body requirements — follow the system prompt's Extraction Principles, Body Format, and Frontmatter Rules (lossless entries; no pronouns; absolute dates; grouped by topic; no `## Metadata`). Two rules that MUST NOT be violated:
- Source markers: put a bare `[session/dialog/....jsonl:1-2,5-6]` at the END of an entry or heading — full path, no `L` prefix, no `Source` label, coarse (hoist a shared marker, never repeat it across bullets), and cite a self-contained line range.
- Source markers: put a bare `[[session/dialog/....jsonl#L1-L2,L5-L6]]` at the END of an entry or heading — double brackets, full path + `#`, `L`-prefixed hyphen ranges, no `Source` label, coarse (hoist a shared marker, never repeat it across bullets), and cite a self-contained line range copied verbatim from the `[Ln]` labels.
- `description`: a thorough, lossless summary — specific enough that the description alone conveys all key information.
## Step 3 — Summary
@ -144,7 +146,7 @@ user_message_create_zh: |
今天:{today}
额外提示:{note}
Session ID:{session_id}
Session 文件:{session_file} ← 请把这个完整路径原样复制进每个来源标记(例如 `[{session_file}:1-2,5-6]`)
Session 文件:{session_file} ← 请把这个完整路径原样复制进每个来源标记(例如 `[[{session_file}#L1-L2,L5-L6]]`)
# 最近的对话
@ -180,7 +182,7 @@ user_message_create_zh: |
⚠️ 关键:你**必须**以**工具调用**(函数调用)方式调用 `daily_write`。不要将 `daily_write name=...` 作为纯文本输出——那样**不会**执行工具,也**不会**保存任何记忆。
正文要求——遵循 system prompt 的提取原则、正文格式与 Frontmatter 规则(无损;不用代词;绝对日期;按主题分组;无 `## Metadata`)。以下两条**绝不能违反**:
- 来源标记:在条目或标题**末尾**放一个裸的 `[session/dialog/....jsonl:1-2,5-6]`——完整路径、无 `L` 前缀、无 `Source` 标签、粒度粗(上提共享标记、绝不在连续条目上重复),且引用自洽完整的行号区间。
- 来源标记:在条目或标题**末尾**放一个裸的 `[[session/dialog/....jsonl#L1-L2,L5-L6]]`——双层方括号、完整路径 + `#`、带 `L` 前缀的连字符区间、无 `Source` 标签、粒度粗(上提共享标记、绝不在连续条目上重复),且行号逐字复制自 `[Ln]` 标签、引用自洽完整的行号区间。
- `description`:详尽的无损总结——具体到仅凭 description 就能传达全部核心信息。
## 步骤 3 — 总结
@ -195,7 +197,7 @@ user_message_update: |
Today: {today}
Extra hint: {note}
Target path: {note_path}
Session file: {session_file} ← copy THIS exact path into every source marker (e.g. `[{session_file}:1-2,5-6]`)
Session file: {session_file} ← copy THIS exact path into every source marker (e.g. `[[{session_file}#L1-L2,L5-L6]]`)
# Recent Conversation
@ -221,7 +223,7 @@ user_message_update: |
The note already has content. Extract new information from the conversation and merge into it.
Extraction rules (apply to ALL new entries) — follow the system prompt's Extraction Principles and Body Format (lossless; no pronouns; absolute dates; grouped by topic). Source markers MUST be a bare `[session/dialog/....jsonl:1-2,5-6]` — full path, no `L` prefix, no `Source` label, coarse (never repeated across bullets), self-contained range.
Extraction rules (apply to ALL new entries) — follow the system prompt's Extraction Principles and Body Format (lossless; no pronouns; absolute dates; grouped by topic). Source markers MUST be a bare `[[session/dialog/....jsonl#L1-L2,L5-L6]]` — double brackets, full path + `#`, `L`-prefixed hyphen ranges copied verbatim from the `[Ln]` labels, no `Source` label, coarse (never repeated across bullets), self-contained range.
Merge rules:
- **Timeline / history entries**: append only, never delete existing entries.
@ -230,6 +232,7 @@ user_message_update: |
Execution:
1. **Call the `edit` tool** for each section that needs updating, with parameters: `path`="{note_path}", `old`=<original fragment>, `new`=<replacement fragment>. You may call `edit` multiple times. Each call MUST be a tool call, not text.
- ⚠️ `edit` is for the note BODY only. NEVER touch the frontmatter block (the `---`-delimited YAML at the top of the file) with `edit` — a malformed YAML edit corrupts the whole note. All frontmatter changes MUST go through the `frontmatter_update` tool.
2. After body changes, **call the `frontmatter_update` tool** to refresh frontmatter, with parameters: `path`="{note_path}", `metadata`={{"name": "<updated filename stem>", "description": "<updated summary>"}}.
- Keep the existing `name` only when it is already the best concise topic/event filename stem. The system will rename the file after your final response.
- Do not add today's date or the daily directory date to `name`.
@ -239,7 +242,7 @@ user_message_update: |
The file exists but its body is empty. Extract and write the full content by **calling the `write` tool** with parameters: `path`="{note_path}", `name`=<name>, `description`=<description>, `content`=<body>.
Body requirements — follow the system prompt's Extraction Principles, Body Format, and Frontmatter Rules (lossless; no pronouns; absolute dates; grouped by topic; no `## Metadata`). Source markers MUST be a bare `[session/dialog/....jsonl:1-2,5-6]` — full path, no `L` prefix, no `Source` label, coarse (never repeated across bullets), self-contained range. `description` must be a thorough, lossless summary that alone conveys all key information.
Body requirements — follow the system prompt's Extraction Principles, Body Format, and Frontmatter Rules (lossless; no pronouns; absolute dates; grouped by topic; no `## Metadata`). Source markers MUST be a bare `[[session/dialog/....jsonl#L1-L2,L5-L6]]` — double brackets, full path + `#`, `L`-prefixed hyphen ranges copied verbatim from the `[Ln]` labels, no `Source` label, coarse (never repeated across bullets), self-contained range. `description` must be a thorough, lossless summary that alone conveys all key information.
## Step 4 — Summary
@ -254,7 +257,7 @@ user_message_update_zh: |
今天:{today}
额外提示:{note}
目标路径:{note_path}
Session 文件:{session_file} ← 请把这个完整路径原样复制进每个来源标记(例如 `[{session_file}:1-2,5-6]`)
Session 文件:{session_file} ← 请把这个完整路径原样复制进每个来源标记(例如 `[[{session_file}#L1-L2,L5-L6]]`)
# 最近的对话
@ -280,7 +283,7 @@ user_message_update_zh: |
笔记已有内容。从对话中提取新信息并合并进去。
提取规则(适用于所有新条目)——遵循 system prompt 的提取原则与正文格式(无损;不用代词;绝对日期;按主题分组)。来源标记必须是裸的 `[session/dialog/....jsonl:1-2,5-6]`——完整路径、无 `L` 前缀、无 `Source` 标签、粒度粗(不在连续条目上重复)、自洽完整的行号区间。
提取规则(适用于所有新条目)——遵循 system prompt 的提取原则与正文格式(无损;不用代词;绝对日期;按主题分组)。来源标记必须是裸的 `[[session/dialog/....jsonl#L1-L2,L5-L6]]`——双层方括号、完整路径 + `#`、带 `L` 前缀的连字符区间(行号逐字复制自 `[Ln]` 标签)、无 `Source` 标签、粒度粗(不在连续条目上重复)、自洽完整的行号区间。
合并规则:
- **时间线 / 历史条目**:仅追加,永远不删除已有条目。
@ -289,6 +292,7 @@ user_message_update_zh: |
执行:
1. **调用 `edit` 工具**更新需要修改的部分,参数为:`path`="{note_path}"、`old`=<原文片段>、`new`=<替换片段>。可以多次调用 `edit`。每次调用必须是工具调用,不是文本。
- ⚠️ `edit` 只能用于笔记**正文**。**绝不**要用 `edit` 碰 frontmatter 块(文件顶部由 `---` 包围的 YAML)——格式错误的 YAML 会损坏整个笔记。所有 frontmatter 修改都必须通过 `frontmatter_update` 工具。
2. 正文变更后,**调用 `frontmatter_update` 工具**刷新 frontmatter,参数为:`path`="{note_path}"、`metadata`={{"name": "<更新后的文件名 stem>", "description": "<更新后的总结>"}}。
- 只有当前 `name` 已经是最合适的简洁主题/事件文件名 stem 时才保留。系统会在你最终回复后负责重命名文件。
- 不要在 `name` 中加入今天日期或日记目录日期。
@ -298,7 +302,7 @@ user_message_update_zh: |
文件存在但正文为空。提取并**调用 `write` 工具**一次性写入完整内容,参数为:`path`="{note_path}"、`name`=<name>、`description`=<description>、`content`=<正文>。
正文要求——遵循 system prompt 的提取原则、正文格式与 Frontmatter 规则(无损;不用代词;绝对日期;按主题分组;无 `## Metadata`)。来源标记必须是裸的 `[session/dialog/....jsonl:1-2,5-6]`——完整路径、无 `L` 前缀、无 `Source` 标签、粒度粗(不在连续条目上重复)、自洽完整的行号区间。`description` 必须是仅凭自身即可传达全部核心信息的详尽无损总结。
正文要求——遵循 system prompt 的提取原则、正文格式与 Frontmatter 规则(无损;不用代词;绝对日期;按主题分组;无 `## Metadata`)。来源标记必须是裸的 `[[session/dialog/....jsonl#L1-L2,L5-L6]]`——双层方括号、完整路径 + `#`、带 `L` 前缀的连字符区间(行号逐字复制自 `[Ln]` 标签)、无 `Source` 标签、粒度粗(不在连续条目上重复)、自洽完整的行号区间。`description` 必须是仅凭自身即可传达全部核心信息的详尽无损总结。
## 步骤 4 — 总结

View file

@ -0,0 +1,128 @@
"""Unit tests for BEAM auto-memory chunked ingestion and line numbering."""
# pylint: disable=missing-class-docstring,missing-function-docstring,protected-access
from agentscope.message import Msg
from reme.components.runtime_context import RuntimeContext
from reme.steps.benchmark.beam.auto_memory import (
BeamAutoMemoryStep,
split_turn_chunks,
)
def _msg(role: str, words: int, created_at: str = "2024-03-01T09:00:00") -> Msg:
return Msg(
name=role,
role=role,
content=[{"type": "text", "text": " ".join(["w"] * words)}],
created_at=created_at,
)
def _dialog(n_turns: int, words_per_msg: int) -> list[Msg]:
messages: list[Msg] = []
for _ in range(n_turns):
messages.append(_msg("user", words_per_msg))
messages.append(_msg("assistant", words_per_msg))
return messages
class TestSplitTurnChunks:
def test_empty(self):
assert not split_turn_chunks([], 100)
def test_disabled_returns_single_chunk(self):
messages = _dialog(3, 10)
chunks = split_turn_chunks(messages, 0)
assert len(chunks) == 1
assert chunks[0] == (0, messages)
def test_under_limit_single_chunk(self):
messages = _dialog(3, 10) # 60 words total
chunks = split_turn_chunks(messages, 100)
assert len(chunks) == 1
assert chunks[0][0] == 0
assert chunks[0][1] == messages
def test_splits_at_turn_boundaries(self):
# 4 turns x 20 words each; limit 40 -> 2 turns per chunk
messages = _dialog(4, 10)
chunks = split_turn_chunks(messages, 40)
assert len(chunks) == 2
offsets = [offset for offset, _ in chunks]
assert offsets == [0, 4]
# Every chunk starts with a user message and ends with an assistant
for _, chunk in chunks:
assert chunk[0].role == "user"
assert chunk[-1].role == "assistant"
# No message lost or duplicated, order preserved
flattened = [m for _, chunk in chunks for m in chunk]
assert flattened == messages
def test_never_splits_inside_a_turn(self):
# One turn alone exceeds the limit -> becomes its own oversized chunk
messages = [
_msg("user", 5),
_msg("assistant", 5),
_msg("user", 50),
_msg("assistant", 50), # 100-word turn > limit 60
_msg("user", 5),
_msg("assistant", 5),
]
chunks = split_turn_chunks(messages, 60)
assert [offset for offset, _ in chunks] == [0, 2, 4]
assert [len(chunk) for _, chunk in chunks] == [2, 2, 2]
def test_offsets_are_original_indices(self):
messages = _dialog(5, 30) # 60 words per turn
chunks = split_turn_chunks(messages, 120)
# 2 turns per chunk -> offsets 0, 4, 8
assert [offset for offset, _ in chunks] == [0, 4, 8]
for offset, chunk in chunks:
for i, msg in enumerate(chunk):
assert msg is messages[offset + i]
def test_multi_assistant_turn_stays_together(self):
messages = [
_msg("user", 10),
_msg("assistant", 10),
_msg("assistant", 10),
_msg("user", 10),
_msg("assistant", 10),
]
chunks = split_turn_chunks(messages, 30)
assert [offset for offset, _ in chunks] == [0, 3]
assert len(chunks[0][1]) == 3
class TestFormatHistoryLineNumbers:
def _step(self, offset: int) -> BeamAutoMemoryStep:
step = BeamAutoMemoryStep(name="beam_auto_memory_step", backend="beam_auto_memory_step")
step.context = RuntimeContext(
session_id="beam_1M_1_batch1",
beam_line_offset=offset,
)
return step
def test_numbers_start_at_one_without_offset(self):
step = self._step(0)
history = step._format_history(_dialog(2, 3))
assert "[L1 | user @" in history
assert "[L4 | assistant @" in history
assert "lines 1-4" in history
assert "session/dialog/beam_1M_1_batch1.jsonl" in history
def test_numbers_use_original_file_offset(self):
step = self._step(40)
history = step._format_history(_dialog(2, 3))
assert "[L41 | user @" in history
assert "[L44 | assistant @" in history
assert "lines 41-44" in history
assert "[L1 " not in history
def test_build_messages_passes_msg_objects_through(self):
step = self._step(0)
messages = _dialog(2, 3)
rebuilt = step._build_messages(messages)
assert [m.id for m in rebuilt] == [m.id for m in messages]