mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-01 07:03:02 +00:00
feat(beam): enhance auto_memory with source markers and pilot ingest tooling
This commit is contained in:
parent
ed89e59ead
commit
db7f718236
7 changed files with 501 additions and 27 deletions
65
benchmark/beam/_pilot_ingest.py
Normal file
65
benchmark/beam/_pilot_ingest.py
Normal 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))
|
||||
128
benchmark/beam/check_source_markers.py
Normal file
128
benchmark/beam/check_source_markers.py
Normal 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"]))
|
||||
|
|
@ -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** |
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 — 总结
|
||||
|
||||
|
|
|
|||
128
tests/unit/test_beam_auto_memory.py
Normal file
128
tests/unit/test_beam_auto_memory.py
Normal 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]
|
||||
Loading…
Add table
Reference in a new issue