feat(benchmark): add BEAM & restructure LongMemEval evaluation framework (#375)
Some checks are pending
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Tests ReMe / Unit Tests - py3.11 (push) Waiting to run
Tests ReMe / Unit Tests - py3.12 (push) Waiting to run
Tests ReMe / Unit Tests - py3.13 (push) Waiting to run
Windows Smoke / CLI smoke - py3.11 (push) Waiting to run

* feat(eval): add LongMemEval evaluation framework with tool_defaults date injection

- Add evaluation/longmemeval/ with run.py, config.yaml, and test scripts
- Add reme/config/longmemeval.yaml for evaluation-specific model config
- Add tool_defaults mechanism to as_agent_wrapper for injecting default
  tool kwargs (uses setdefault so LLM-provided values take priority)
- Pass tool_defaults={'daily_write': {'date': day}} in auto_memory to
  ensure notes always use the correct historical date
- Add timestamp interpolation (_interpolate_timestamps) in auto_memory
  for filling missing created_at fields via linear interpolation
- Evaluation pipeline: ingest sessions -> dream -> search -> answer -> judge
- Uses qwen3.6-flash for memory, qwen3.7-max for answer/judge

* chore: gitignore logs/results/demo.py, keep empty dirs

* chore: update .gitignore

* feat(eval): add multiprocessing and session time filtering to longmemeval runner

- Replace async execution with synchronous + multiprocessing for parallel item evaluation - Add filter_future_sessions option to only ingest sessions <= question date - Add question_types filtering in config - Add result summary with binary accuracy and avg score - Update config defaults (oracle variant, 50 items, 32 workers) - Minor code style fixes in agent_wrapper and auto_memory

* feat: add bench_query_step with ReAct agent for benchmark query phase

- Add BenchQueryStep using agent_wrapper with search job tool
- Replace manual search+LLM answer in run.py with bench_query_job
- Remove unused answer LLM config from longmemeval.yaml
- Register benchmark step module in steps/__init__.py

* feat: add start_date/end_date time filter support for search job

- Add _extract_date_from_path to extract validated YYYY-MM-DD from chunk paths
- Add start_date/end_date filtering in _matches_search_filter
- Implement progressive recall in FaissLocalFileStore.vector_search
- Promote start_date/end_date from context to search_filter in SearchStep
- Add start_date/end_date parameters to search job in default.yaml
- Add unit tests for date filter functionality

* fix: validate/normalize date filters and harden _extract_date_from_path

Address three code-review comments on the time_filter search feature:

1. Validate/normalize start_date and end_date before string comparison.
   _matches_search_filter does lexicographic comparison against path_date
   (always canonical YYYY-MM-DD). Raw caller values like '2026-2-28' or
   'abc' would produce silently wrong results. Now SearchStep normalizes
   valid dates via extract_daily_date (with strptime fallback for
   non-zero-padded input) and silently ignores invalid dates with a
   logger.warning, removing them from the filter.

2. Clarify behavior for paths without embedded dates.
   Added optional strict_date_filter parameter (default False). When True
   and at least one date bound is active, chunks whose path yields no date
   (e.g. digest/personal/topic.md) are excluded. When False (default),
   the existing behavior is preserved — dateless paths pass through.

3. Harden _extract_date_from_path against non-standard suffixes.
   Previously parts[1].split('.')[0] accepted '2026-05-18.anything' as a
   valid date. Now only exact 'YYYY-MM-DD' (dir) and 'YYYY-MM-DD.md'
   (day-index) forms are accepted.

* feat(eval): LLM-as-Judge per-type prompt routing, binary-only, progress tracking

- Remove 0-5 score metric, keep only binary (yes/no) classification
- Load per-question-type judge prompts from llm-as-judge.json
  (temporal-reasoning, knowledge-update, single-session-preference, __default__)
- Replace SCORE_JUDGE_PROMPT with type-specific BINARY_JUDGE_PROMPT template
- judge_response(): parameter 'metric' -> 'question_type', returns single 'judgment'
- Summary output: add per-type accuracy breakdown, remove score stats
- Add progress tracking: background thread prints PROGRESS every 10min
- Add FINAL progress line and total elapsed time on completion
- Add --log-level, --reme-log-level, -q CLI arguments
- Parallel mode: pool.map -> pool.imap_unordered for real-time progress
- config.yaml: full oracle (10000 items), 32 workers, all question types
- Add kill.sh (process cleanup) and run_async.sh (background eval launcher)

* docs: add LongMemEval oracle evaluation results (61.6% accuracy)

* feat(bench): add MAX_ITERATION limit to BenchQueryStep and add _auto_memory.yaml

* feat: add golden session benchmark & eval_only mode with refined prompt

- Add benchmark/longmemeval/run_golden_session.py for golden session evaluation
- Refine PROMPTED_SYSTEM_PROMPT: concise answer rule, remove 'Information not found' fallback
- Add eval_only mode to run.py (--eval_only flag)
- Add multiple eval config variants (evalonly, full, test5)
- Add analyze_results.py for result parsing
- Update auto_memory.yaml, longmemeval.yaml, application_config
- Update result-longmemeval.md with latest evaluation results
- Add benchmark results to .gitignore

* update: refine answer prompts and increase max iteration to 6 - Tighten prompted-answer system prompt for more concise output - Comment out 'Information not found' fallback rule - Increase MAX_ITERATION from 5 to 6 in bench_query - Add recall_eval.py - Update evaluation results

* feat(chunker): add dedicated JSON and JSONL file chunkers (cherry-pick from upstream #325)

- Add JsonFileChunker: structure-aware chunking preserving nested key paths,
  optional list-to-dict conversion, size measured by json.dumps() char count
- Add JsonlFileChunker: line-aligned sliding-window chunking with configurable
  overlap, supports char/byte mode switching
- Register both chunkers in default.yaml (json for .json, jsonl for .jsonl)
- Add comprehensive unit tests (21 + 20 test cases)

* feat(service): add CLI service for local job execution (from upstream #334)

- Introduce CliService to execute single jobs locally without serving ports
- Add prepare_start_config and should_precheck_start functions for CLI job setup
- Update reme start command to use CLI service when job argument is provided
- Add show_metadata to client kwargs for optional CLI metadata output
- Add unit tests for CLI service functionality and configuration handling

* feat(steps): add BM25/vector search steps, Python execute step, and draft steps (from upstream #334)

- Add Bm25SearchStep for plain BM25 keyword search with tool_context deduplication
- Add VectorSearchStep for plain vector search with tool_context deduplication
- Add PythonExecuteStep to run Python code in subprocess with timeout handling
- Add AddDraftStep/ReadAllDraftStep for draft accumulation scoped by tool context
- Update SearchStep with tool_context dedup, dynamic default limit via REME_SEARCH_LIMIT env,
  and candidate_multiplier default changed from 3.0 to 5.0
- Add comprehensive unit tests for all new steps

* feat(search): add tool context deduplication and improve search configuration (#321)

* feat(search): add tool context deduplication and improve search configuration

- Modify _make_tool methods to accept and inject tool_context_id parameter
- Add tool_context_id handling in AS and CC agent wrappers
- Increase search candidate multiplier from 3.0 to 5.0 in default config
- Extend HTTP client timeout from 30s to 3600s
- Add tool context deduplication logic to prevent duplicate search results
- Implement TTL-based expiration for seen chunks in tool contexts
- Add comprehensive unit tests for tool context deduplication behavior
- Update .gitignore to exclude longmemeval directory
- Add time import for timestamp functionality in search step

* refactor(search): replace time module with datetime for timestamp generation

- Removed unused time import
- Added static method _now_ts using datetime.timestamp
- Updated clock parameter to use _now_ts method instead of time.time
- Maintained same timestamp precision and functionality

* fix(file_io): fix risk of out-workspace paths (#322)

* fix(file_io): fix risk of out-workspace paths

* chore(file_io): remove unused unittest file

* fix(as_embedding): support both agentscope 2.0.2 and 2.0.3 (#323)

2.0.3 promoted `dimensions` to a required first-class constructor
argument while keeping a backfill from `parameters.dimensions`; 2.0.2
has no such argument and reads `dimensions` from `Parameters`. Keep
`dimensions` in `Parameters` for both versions and, when the model
constructor accepts `dimensions`, pass `dimensions=None` so 2.0.3's
backfill promotes it out of `parameters`.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Bump version to 0.4.0.7

* refactor: delegate LLM-as-Judge to answer_judge_step and update eval config/results

- run.py: replace inline judge logic with judge_response_via_job using app.run_job('answer_judge')
- longmemeval.yaml: expand benchmark configuration
- bench_query.py: enhance benchmark query step
- result-longmemeval.md: update evaluation results
- judge_all_plus_results.json: add judge all-plus results

* refactor: split longmemeval.yaml into lme.yaml/beam.yaml and unify job names

- Split reme/config/longmemeval.yaml into lme.yaml (LongMemEval) and beam.yaml (BEAM)
- Unify job names across both configs: agentic_answer, answer_judge, context_answer
- Update evaluation/longmemeval/run.py and evaluation/beam/run_beam_eval.py to use unified job names
- Update all evaluation config YAMLs to reference lme.yaml
- Add BEAM benchmark step implementations (agentic_answer, context_answer, llm_judge)
- Remove obsolete config_test5.yaml and test_5sessions.py

* eval: BEAM 100K & LongMemEval cleaned-S 评测结果记录

- BEAM 100K eval-only (32并发, 20 case): Agentic 0.631, Prompted 0.468
- LongMemEval final GT (500题): Agentic 89.0%, Prompted 83.6%
- 新增 benchmark/result-beam.md, benchmark/result-longmemeval.md
- benchmark/beam/config.yaml: num_workers=32

* refactor: restructure benchmark directory and clean up gitignore rules

- Consolidate benchmark outputs to benchmark/results/ with .gitkeep
- Remove old benchmark scripts, configs and result files from benchmark/beam/ and benchmark/longmemeval/
- Add datasets/README.md and datasets/README_EN.md with download instructions
- Add datasets/longmemeval/download.py and final_groundtruth_cleaned_s.json
- Add memory_workspaces .gitkeep placeholders
- Restructure .gitignore: fix duplicate entries, add BEAM dataset exclusion, refine logs/results ignore patterns
- Remove stale result-beam.md and result-longmemeval.md from project root

* chore: clean up longmemeval benchmark scripts and update dataset docs

- Remove obsolete longmemeval benchmark runner/stats scripts

- Update datasets/longmemeval README and add Chinese translation

- Clean up final_groundtruth_cleaned_s.json

* docs(benchmark): add reproduction guide for LongMemEval and BEAM

- Add bilingual README for benchmark runners (EN/ZH)

- Cover prerequisites, dataset download, run commands, configs, outputs, logs, and kill.sh

* refactor: migrate auto_memory steps from evolve to benchmark-specific modules

- Split auto_memory into beam and lme benchmark-specific implementations
- Add auto_memory.py and auto_memory.yaml under steps/benchmark/beam and steps/benchmark/lme
- Slim down evolve/auto_memory.py and auto_memory.yaml to shared base only
- Remove obsolete evolve/_auto_memory.yaml
- Update benchmark run.py, config YAMLs, and step __init__.py registrations
- Update llm_judge and context_answer minor adjustments
- Remove outdated test_lme_final_answer_review.py

* revert(as_agent_wrapper): sync with upstream/main

Remove local-only comment to keep file identical with upstream/main.

* style: add trailing commas in benchmark __init__.py __all__ lists

* chore: disable vector_weight range assertion in SearchStep

* chore: add tests/integration/logs/ to .gitignore

* refactor: replace scipy.stats.kendalltau with pure numpy implementation

scipy is not listed in project dependencies. Implement Kendall's tau-b
rank correlation using only numpy to remove the undeclared dependency.

* feat(benchmark): add binary score metrics, update BEAM 1M results, and improve LLM retry/prompt config

- benchmark/beam/run.py: add binary score calculation per rubric item and per-type/overall binary stats
- benchmark/beam/config.yaml: switch to 1M dataset, reduce workers to 18
- benchmark/result-beam.md: add 1M evaluation results with binary scores
- benchmark/result-longmemeval.md: minor formatting
- reme/config/beam.yaml: increase max_retries to 5 and add retry_delay 5.0 for all LLM components
- reme/config/lme.yaml: increase max_retries to 5 and add retry_delay for judge/prompted/bench components
- reme/steps/benchmark/lme/agentic_answer.yaml: improve search strategy and answer rules prompts

* fix(benchmark): fix line-too-long and add pylint disable for main()

* refactor(longmemeval): use single cleaned-S dataset with embedded ground truth

- Switch to agentscope-ai/ReMe_longmemeval_clean_s_v2 HuggingFace source
- Remove separate final_groundtruth_cleaned_s.json (ground truth now in data file)
- Simplify download.py to fetch only longmemeval_s_reme_cleaned.json
- Remove dataset.variant and dataset.ground_truth_path config options
- Update benchmark and datasets READMEs to reflect new workflow
- Update .gitignore for new dataset filename

* fix: rename loop variable to avoid pylint redefined-outer-name warning

* refactor(benchmark): restructure datasets/memory_workspaces into benchmark and simplify auto_memory steps

* refactor(benchmark): extract BaseAgenticAnswerStep into base module

- Add reme/steps/benchmark/base/agentic_answer.py with shared agentic answer logic
- Refactor beam/lme AgenticAnswerStep to inherit from BaseAgenticAnswerStep
- Simplify lme/context_answer.py and update context_answer.yaml
- Update result-longmemeval.md with latest evaluation results (agentic 91.0%)

* refactor(benchmark): remove context_answer steps and unused configs

- Remove beam/lme context_answer job definitions and step implementations
- Remove prompted LLM component from beam.yaml and lme.yaml
- Delete jinli_lme.yaml (no longer needed)
- Simplify benchmark run.py scripts
- Clean up .gitkeep files and update .gitignore
- Remove unused import in search.py

* chore: remove benchmark/results/.gitkeep

---------

Co-authored-by: sa-buc <jiangniurou.xyf@dail-algo011164204033.ET135>
Co-authored-by: jinliyl <6469360+jinliyl@users.noreply.github.com>
Co-authored-by: imrewce <wce@pku.edu.cn>
Co-authored-by: Sen Huang <48879559+ployts@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
xyf2020 2026-07-21 19:09:50 +08:00 • committed by GitHub
parent e7d44f6f3b
commit 7b1da5a9ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 5080 additions and 4834 deletions

18
.gitignore vendored
View file

@ -2,8 +2,8 @@
.DS_Store
.idea/
.vscode/
*.code-workspace
.qoder/
*.code-workspace
# Local environment
.env
@ -54,5 +54,17 @@ vault/
docs/_build/
site/
evaluation/
datasets/
# Memory workspaces (keep dirs, ignore contents)
benchmark/memory_workspaces/
# LongMemEval raw data (download via download.py)
benchmark/datasets/longmemeval/longmemeval_s_reme_cleaned.json
# BEAM dataset (clone via git)
benchmark/datasets/BEAM/
# Benchmark outputs
benchmark/results/
# integration tests outputs
tests/integration/logs/

145
benchmark/README.md Normal file
View file

@ -0,0 +1,145 @@
[中文版 / Chinese version](./README_ZH.md)
# ReMe Benchmarks
Reproduction guide for the two memory benchmarks shipped with ReMe:
- **LongMemEval** — long-term memory over multi-session chat histories.
- **BEAM** — memory capability over long-context chat cases with rubric-based judging.
Each benchmark runs its own end-to-end pipeline: ingest sessions into an isolated
per-item workspace, answer probing questions via an agentic (ReAct) mode,
then score answers with an LLM-as-judge.
## 1. Prerequisites
Install ReMe with dev + core extras (Python 3.11+):
```bash
pip install -e ".[dev,core]"
```
Configure model credentials in a project-root `.env` file (copied from `example.env`).
The runners auto-load `.env` from the repository root. Required variables typically include:
```bash
LLM_API_KEY=...
LLM_BASE_URL=...
EMBEDDING_API_KEY=...
EMBEDDING_BASE_URL=...
```
Model names and component wiring live in the ReMe configs referenced by each benchmark
(`reme/config/lme.yaml` and `reme/config/beam.yaml`).
## 2. Download Datasets
See [`datasets/README_EN.md`](datasets/README_EN.md) for full details.
**LongMemEval** (downloaded from a HuggingFace mirror):
```bash
cd benchmark/datasets/longmemeval
python download.py # downloads the cleaned-S dataset; skips if already present
```
**BEAM** (public repository, cloned into `benchmark/datasets/`):
```bash
cd benchmark/datasets
git clone https://github.com/mohammadtavakoli78/BEAM.git
```
## 3. Run LongMemEval
From the repository root:
```bash
python benchmark/longmemeval/run.py
python benchmark/longmemeval/run.py --config benchmark/longmemeval/config.yaml
python benchmark/longmemeval/run.py -q # quiet: only eval-level logs
python benchmark/longmemeval/run.py --log-level WARNING # reduce eval runner logs
python benchmark/longmemeval/run.py --reme-log-level WARNING # reduce reme internal logs
python benchmark/longmemeval/run.py --eval_only # reuse existing workspaces, query + judge only
```
### Pipeline
1. Load the dataset (ground truth is embedded in the data file).
2. For each item, create an isolated workspace and ingest sessions in chronological order.
3. Trigger `auto_dream` when consecutive sessions cross the configured hour (default 23:00).
4. Answer each question via agentic (ReAct) mode.
5. Judge the answer (binary yes/no) with the `answer_judge` job and print per-type accuracy.
### Key config — `benchmark/longmemeval/config.yaml`
| Key | Meaning |
| --- | --- |
| `dataset.path` | Dataset file to evaluate (e.g. `longmemeval_s_reme_cleaned.json`); ground truth is included. |
| `dataset.start_index` / `num_items` | Slice of items to evaluate. |
| `dataset.question_types` | Filter by question type; empty = all. |
| `dataset.workspace_root` | Per-item workspace root (`benchmark/memory_workspaces/longmemeval-s`). |
| `evaluation.num_workers` | `0` = auto (cpu-2), `1` = sequential, `>1` = parallel. |
| `evaluation.filter_future_sessions` | Only ingest sessions with timestamp ≤ `question_date`. |
| `reme.config` | ReMe config used (`lme.yaml`). |
| `reme.dream_trigger_hour` / `dream_scan_days` / `dream_max_units` | Dream triggering behavior. |
| `output.dir` | Results directory (`benchmark/results/longmemeval`). |
## 4. Run BEAM
From the repository root:
```bash
python benchmark/beam/run.py
python benchmark/beam/run.py --config benchmark/beam/config.yaml
python benchmark/beam/run.py -q # quiet
python benchmark/beam/run.py --eval_only # reuse existing workspaces, query + judge only
```
### Pipeline
1. For each case, load `chat.json` and convert each batch into a ReMe session.
2. Ingest sessions in chronological order into an isolated workspace, then `digest_update`.
3. Answer each probing question via agentic (ReAct) mode.
4. Score answers with BEAM's rubric-based `answer_judge` job and print per-type averages.
### Key config — `benchmark/beam/config.yaml`
| Key | Meaning |
| --- | --- |
| `dataset.beam_root` | BEAM dataset root (`benchmark/datasets/BEAM`). |
| `dataset.chat_size` | Variant to run: `100K` / `500K` / `1M` / `10M`. |
| `dataset.case_ids` | Specific cases (e.g. `["1","2"]`); empty = all cases. |
| `dataset.start_index` / `num_items` | Case pagination (`num_items` `0` = all). |
| `dataset.workspace_root` | Per-case workspace root (`benchmark/memory_workspaces/beam`). |
| `evaluation.num_workers` | `0` = auto, `1` = sequential, `>1` = parallel. |
| `reme.config` | ReMe config used (`beam.yaml`). |
| `output.dir` | Results directory (`benchmark/results/beam`). |
## 5. Outputs & Logs
- **Results**: JSON files written to `output.dir`
(`results_<timestamp>.json` for LongMemEval,
`results_<chat_size>_<timestamp>.json` for BEAM). A summary with per-type
accuracy/score is also printed to the console.
- **Logs**: when `output.log_to_file` is enabled, per-run logs are written to
`logs/<log_prefix>_<timestamp>/` (a `runner.log` plus one `worker-<pid>.log`
per worker process).
## 6. Stopping a Run
Parallel runs spawn a process tree. To terminate a run and all its workers cleanly:
```bash
bash benchmark/kill.sh <PID>
```
The script gracefully sends `SIGTERM` to the whole process tree, then escalates to
`SIGKILL` for any process that does not exit within 5 seconds.
## 7. Reference Results
Recorded evaluation results are available in:
- [`result-longmemeval.md`](./result-longmemeval.md)
- [`result-beam.md`](./result-beam.md)

142
benchmark/README_ZH.md Normal file
View file

@ -0,0 +1,142 @@
# ReMe 评测复现说明
ReMe 内置两个记忆能力评测基准的复现指南:
- **LongMemEval** —— 面向多轮多会话历史的长期记忆能力评测。
- **BEAM** —— 面向长上下文对话场景、基于评分细则(rubric)打分的记忆能力评测。
每个基准都会运行完整的端到端流程:将会话摄入独立的按条目隔离的工作区,
以 agentic(ReAct)模式回答探测问题,
最后由 LLM-as-judge 对答案进行打分。
## 1. 环境准备
安装 ReMe 及 dev + core 附加依赖(Python 3.11+):
```bash
pip install -e ".[dev,core]"
```
在项目根目录配置 `.env`(可从 `example.env` 复制),运行脚本会自动从仓库根目录加载 `.env`。
通常需要以下变量:
```bash
LLM_API_KEY=...
LLM_BASE_URL=...
EMBEDDING_API_KEY=...
EMBEDDING_BASE_URL=...
```
模型名称与组件装配位于各基准引用的 ReMe 配置中
(`reme/config/lme.yaml` 与 `reme/config/beam.yaml`)。
## 2. 下载数据集
完整说明见 [`datasets/README.md`](datasets/README.md)。
**LongMemEval**(从 HuggingFace 镜像下载):
```bash
cd benchmark/datasets/longmemeval
python download.py # 下载 cleaned-S 数据文件,已存在则自动跳过
```
**BEAM**(公开仓库,clone 到 `benchmark/datasets/` 下):
```bash
cd benchmark/datasets
git clone https://github.com/mohammadtavakoli78/BEAM.git
```
## 3. 运行 LongMemEval
在仓库根目录执行:
```bash
python benchmark/longmemeval/run.py
python benchmark/longmemeval/run.py --config benchmark/longmemeval/config.yaml
python benchmark/longmemeval/run.py -q # 安静模式:仅评测级日志
python benchmark/longmemeval/run.py --log-level WARNING # 降低评测 runner 日志
python benchmark/longmemeval/run.py --reme-log-level WARNING # 降低 reme 内部日志
python benchmark/longmemeval/run.py --eval_only # 复用已有工作区,仅执行查询 + 评判
```
### 流程
1. 加载数据集(ground truth 已内嵌在数据文件中)。
2. 为每个条目创建独立工作区,按时间顺序摄入会话。
3. 当相邻会话跨越配置的时刻(默认 23:00)时触发 `auto_dream`。
4. 以 agentic(ReAct)模式回答每个问题。
5. 通过 `answer_judge` 任务对答案做二元(yes/no)评判,并输出各类型准确率。
### 关键配置 —— `benchmark/longmemeval/config.yaml`
| 配置项 | 含义 |
| --- | --- |
| `dataset.path` | 待评测的数据集文件(如 `longmemeval_s_reme_cleaned.json`),已包含 ground truth。 |
| `dataset.start_index` / `num_items` | 评测条目的切片范围。 |
| `dataset.question_types` | 按问题类型过滤,空表示全部。 |
| `dataset.workspace_root` | 条目工作区根目录(`benchmark/memory_workspaces/longmemeval-s`)。 |
| `evaluation.num_workers` | `0` = 自动(cpu-2),`1` = 串行,`>1` = 并行。 |
| `evaluation.filter_future_sessions` | 仅摄入时间戳 ≤ `question_date` 的会话。 |
| `reme.config` | 使用的 ReMe 配置(`lme.yaml`)。 |
| `reme.dream_trigger_hour` / `dream_scan_days` / `dream_max_units` | dream 触发行为。 |
| `output.dir` | 结果目录(`benchmark/results/longmemeval`)。 |
## 4. 运行 BEAM
在仓库根目录执行:
```bash
python benchmark/beam/run.py
python benchmark/beam/run.py --config benchmark/beam/config.yaml
python benchmark/beam/run.py -q # 安静模式
python benchmark/beam/run.py --eval_only # 复用已有工作区,仅执行查询 + 评判
```
### 流程
1. 为每个 case 加载 `chat.json`,将每个 batch 转换为一个 ReMe 会话。
2. 按时间顺序将会话摄入独立工作区,随后执行 `digest_update`。
3. 以 agentic(ReAct)模式回答每个探测问题。
4. 通过 BEAM 基于 rubric 的 `answer_judge` 任务打分,并输出各类型平均分。
### 关键配置 —— `benchmark/beam/config.yaml`
| 配置项 | 含义 |
| --- | --- |
| `dataset.beam_root` | BEAM 数据集根目录(`benchmark/datasets/BEAM`)。 |
| `dataset.chat_size` | 运行的变体:`100K` / `500K` / `1M` / `10M`。 |
| `dataset.case_ids` | 指定 case(如 `["1","2"]`),空表示全部。 |
| `dataset.start_index` / `num_items` | case 分页(`num_items` 为 `0` 表示全部)。 |
| `dataset.workspace_root` | case 工作区根目录(`benchmark/memory_workspaces/beam`)。 |
| `evaluation.num_workers` | `0` = 自动,`1` = 串行,`>1` = 并行。 |
| `reme.config` | 使用的 ReMe 配置(`beam.yaml`)。 |
| `output.dir` | 结果目录(`benchmark/results/beam`)。 |
## 5. 输出与日志
- **结果**:JSON 文件写入 `output.dir`
(LongMemEval 为 `results_<timestamp>.json`,
BEAM 为 `results_<chat_size>_<timestamp>.json`)。同时控制台会打印含各类型
准确率/分数的汇总。
- **日志**:当 `output.log_to_file` 开启时,每次运行的日志写入
`logs/<log_prefix>_<timestamp>/`(包含一个 `runner.log` 及每个 worker 进程的
`worker-<pid>.log`)。
## 6. 终止运行
并行运行会派生进程树。若要干净地终止某次运行及其全部 worker:
```bash
bash benchmark/kill.sh <PID>
```
该脚本会先向整个进程树发送 `SIGTERM` 优雅终止,对 5 秒内未退出的进程再升级为 `SIGKILL`。
## 7. 参考结果
已记录的评测结果见:
- [`result-longmemeval.md`](./result-longmemeval.md)
- [`result-beam.md`](./result-beam.md)

View file

@ -0,0 +1,23 @@
# BEAM evaluation configuration
# This file controls what/how to evaluate.
dataset:
beam_root: "benchmark/datasets/BEAM" # BEAM dataset root
chat_size: "1M" # 100K | 500K | 1M | 10M (dataset variant)
case_ids: [] # empty = all cases; or ["1", "2", "3"]
start_index: 0 # first case index (for pagination)
num_items: 0 # 0 = all cases; >0 = limit
workspace_root: "benchmark/memory_workspaces/beam" # workspace root for case workspaces
evaluation:
num_workers: 18 # 0 = auto; 1 = sequential; >1 = parallel (per-case)
reme:
config: "beam.yaml" # reme config (in reme/config/)
output:
dir: "benchmark/results/beam"
log_dir: "logs" # log directory (relative to project root)
log_prefix: "beam" # benchmark name used in log filenames
log_to_console: true
log_to_file: true

791
benchmark/beam/run.py Normal file
View file

@ -0,0 +1,791 @@
"""BEAM evaluation runner for ReMe.
Evaluates ReMe's memory capability using the BEAM dataset.
Each case gets an isolated workspace; chat.json batches are ingested as
sessions in chronological order; finally probing questions are answered
via an agentic (ReAct) approach, then
judged by BEAM's rubric-based LLM-as-judge.
Usage:
python benchmark/beam/run.py
python benchmark/beam/run.py --config benchmark/beam/config.yaml
python benchmark/beam/run.py -q # quiet: only eval-level logs
python benchmark/beam/run.py --log-level WARNING # reduce eval runner logs
python benchmark/beam/run.py --reme-log-level WARNING # reduce reme internal logs
python benchmark/beam/run.py --eval_only # query+judge only, reuse existing workspace
"""
import json
import logging
import os
import re
import shutil
import time
import threading
from datetime import datetime
from pathlib import Path
import yaml
from dotenv import load_dotenv
# Load .env from project root
_PROJECT_ROOT = Path(__file__).parent.parent.parent
load_dotenv(_PROJECT_ROOT / ".env")
# Workspace root — read from config.yaml (dataset.workspace_root)
_WORKSPACE_ROOT_DEFAULT = "benchmark/memory_workspaces/beam"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
_DEFAULT_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(message)s"
logging.basicConfig(level=logging.INFO, format=_DEFAULT_LOG_FORMAT)
logger = logging.getLogger("beam")
# Noisy library loggers silenced by default
_NOISY_LOGGERS = [
"httpx",
"httpcore",
"openai",
"uvicorn",
"multipart",
"asyncio",
"watchfiles",
"filelock",
]
def setup_logging(
log_level: str,
reme_log_level: str,
log_dir: str | None = None,
):
"""Configure logging for the eval runner and reme internals.
Args:
log_level: Level for the eval runner logger (DEBUG/INFO/WARNING/ERROR).
reme_log_level: Level for reme's internal loguru logger.
log_dir: Per-run log directory (absolute path). None = no file logging.
"""
numeric = getattr(logging, log_level.upper(), logging.INFO)
# Eval runner logger
logging.getLogger().setLevel(numeric)
logger.setLevel(numeric)
# Suppress noisy library loggers when above DEBUG
if numeric > logging.DEBUG:
for name in _NOISY_LOGGERS:
lib_logger = logging.getLogger(name)
lib_logger.setLevel(max(numeric, logging.WARNING))
# Add file handler for eval runner if log_dir is specified
if log_dir:
os.makedirs(log_dir, exist_ok=True)
log_filepath = os.path.join(log_dir, "runner.log")
file_handler = logging.FileHandler(log_filepath, encoding="utf-8")
file_handler.setLevel(numeric)
file_handler.setFormatter(logging.Formatter(_DEFAULT_LOG_FORMAT))
logging.getLogger().addHandler(file_handler)
logger.info(f"Eval runner log file: {log_filepath}")
# Reme internal logger (loguru) — will be applied per-worker via _configure_worker
os.environ["REME_LOG_LEVEL"] = reme_log_level.upper()
if log_dir:
os.environ["REME_LOG_DIR"] = log_dir
def _configure_worker(
log_level: str,
reme_log_level: str,
log_dir: str | None = None,
):
"""Set up logging inside a multiprocessing worker process.
Must be called at the top of each worker because child processes inherit
parent state but loguru sinks are NOT shared across fork/spawn.
"""
numeric = getattr(logging, log_level.upper(), logging.INFO)
logging.basicConfig(level=numeric, format=_DEFAULT_LOG_FORMAT, force=True)
logging.getLogger("beam").setLevel(numeric)
if numeric > logging.DEBUG:
for name in _NOISY_LOGGERS:
logging.getLogger(name).setLevel(max(numeric, logging.WARNING))
# Add file handler for eval runner in worker process
if log_dir:
os.makedirs(log_dir, exist_ok=True)
pid = os.getpid()
log_filepath = os.path.join(log_dir, f"worker-{pid}.log")
file_handler = logging.FileHandler(log_filepath, encoding="utf-8")
file_handler.setLevel(numeric)
file_handler.setFormatter(logging.Formatter(_DEFAULT_LOG_FORMAT))
logging.getLogger().addHandler(file_handler)
# Re-initialize loguru for reme internals at the desired level
from reme.utils import get_logger
reme_log_dir = log_dir or "logs"
get_logger(log_dir=reme_log_dir, level=reme_log_level.upper(), force_init=True)
# ---------------------------------------------------------------------------
# Config loading
# ---------------------------------------------------------------------------
def load_eval_config(config_path: str | None = None) -> dict:
"""Load evaluation config yaml with env-var expansion."""
if config_path is None:
config_path = str(Path(__file__).parent / "config.yaml")
with open(config_path, encoding="utf-8") as f:
raw = f.read()
# Expand ${VAR} and ${VAR:-default}
def _expand(m):
expr = m.group(1)
if ":-" in expr:
key, default = expr.split(":-", 1)
return os.environ.get(key, default)
return os.environ.get(expr, "")
raw = re.sub(r"\$\{([^}]+)\}", _expand, raw)
return yaml.safe_load(raw)
# ---------------------------------------------------------------------------
# BEAM data loading
# ---------------------------------------------------------------------------
def parse_beam_time_anchor(time_str: str) -> datetime:
"""Parse BEAM time_anchor format: 'March-15-2024' -> datetime."""
for fmt in ("%B-%d-%Y", "%b-%d-%Y"):
try:
return datetime.strptime(time_str, fmt)
except ValueError:
continue
raise ValueError(f"Cannot parse time_anchor: {time_str!r}")
def load_beam_chat(chat_path: Path, chat_size: str, case_id: str) -> list[dict]:
"""Load BEAM chat.json and convert to ReMe session format.
Each batch becomes one session with all its turns flattened.
Each turn resolves its own time_anchor independently; turns without
an explicit time_anchor inherit from the most recent preceding turn.
Returns list of sessions, each with:
- session_id: str
- date: str (YYYY-MM-DD) — derived from the *first* turn's time
- messages: list[dict] with name, role, content, created_at
"""
with open(chat_path, encoding="utf-8") as f:
batches = json.load(f)
sessions = []
for batch in batches:
batch_num = batch["batch_number"]
# Resolve batch-level fallback (used when no turn has a time_anchor)
batch_anchor = batch.get("time_anchor")
if not batch_anchor:
batch_anchor = "January-1-2024"
# Flatten all turns, resolving time_anchor per turn
messages = []
prev_dt = None # carries forward from previous turn
first_dt = None # for session-level date
for turn in batch["turns"]:
# Find this turn's own time_anchor from its messages
turn_anchor = None
for msg in turn:
if msg.get("time_anchor"):
turn_anchor = msg["time_anchor"]
break
if turn_anchor:
dt = parse_beam_time_anchor(turn_anchor)
elif prev_dt is not None:
dt = prev_dt # inherit from previous turn
else:
dt = parse_beam_time_anchor(batch_anchor)
if first_dt is None:
first_dt = dt
prev_dt = dt
for msg in turn:
role = msg["role"]
messages.append(
{
"name": role,
"role": role,
"content": msg["content"],
"created_at": dt.strftime("%Y-%m-%dT%H:%M:%S"),
},
)
sessions.append(
{
"session_id": f"beam_{chat_size}_{case_id}_batch{batch_num}",
"date": first_dt.strftime("%Y-%m-%d"),
"messages": messages,
},
)
return sessions
def get_available_cases(beam_root: Path, chat_size: str) -> list[str]:
"""Return sorted list of case IDs for a given chat size."""
chats_dir = beam_root / "chats" / chat_size
if not chats_dir.exists():
return []
return sorted(
[d.name for d in chats_dir.iterdir() if d.is_dir()],
key=int,
)
# ---------------------------------------------------------------------------
# Answer generation
# ---------------------------------------------------------------------------
async def answer_question_agentic(app, question: str) -> tuple[str, dict]:
"""Answer a probing question using ReMe's agentic_answer job.
Returns (answer, metadata)
"""
query_resp = await app.run_job(
"agentic_answer",
query=question,
)
answer = (query_resp.answer or "").strip()
return answer, {"mode": "agentic"}
# ---------------------------------------------------------------------------
# BEAM rubric-based LLM-as-Judge
# ---------------------------------------------------------------------------
async def judge_answer(
app,
question: str,
llm_response: str,
rubric: list[str],
question_type: str = "",
) -> dict:
"""Judge an answer via the answer_judge job (beam_rubric_judge_step)."""
judge_resp = await app.run_job(
"answer_judge",
llm_response=llm_response,
rubric=rubric,
probing_question=question,
question_type=question_type,
)
result = {
"llm_judge_score": (judge_resp.metadata or {}).get("llm_judge_score", 0.0),
"llm_judge_responses": (judge_resp.metadata or {}).get("llm_judge_responses", []),
}
# Include event_ordering extra metrics if present
eo = (judge_resp.metadata or {}).get("event_ordering")
if eo:
result["event_ordering"] = eo
return result
# ---------------------------------------------------------------------------
# Main evaluation pipeline
# ---------------------------------------------------------------------------
async def evaluate_case(eval_config: dict, case_id: str, eval_only: bool = False) -> dict:
"""Evaluate a single BEAM case end-to-end.
Args:
eval_config: The evaluation configuration dict.
case_id: The case directory name (e.g. "1").
eval_only: If True, skip ingestion and only run query+judge
using the existing workspace.
Returns:
A results dict with all questions, answers, and judgments.
"""
from reme import Application
from reme.config import resolve_app_config
dataset_cfg = eval_config["dataset"]
chat_size = dataset_cfg["chat_size"]
beam_root = _PROJECT_ROOT / dataset_cfg.get("beam_root", "benchmark/datasets/BEAM")
chat_path = beam_root / "chats" / chat_size / case_id / "chat.json"
probing_questions_path = beam_root / "chats" / chat_size / case_id / "probing_questions" / "probing_questions.json"
if not chat_path.exists():
raise FileNotFoundError(f"Chat file not found: {chat_path}")
if not probing_questions_path.exists():
raise FileNotFoundError(f"Probing questions not found: {probing_questions_path}")
logger.info(
"[Case %s] size=%s%s",
case_id,
chat_size,
" [eval_only]" if eval_only else "",
)
# Workspace setup
workspace_root = _PROJECT_ROOT / dataset_cfg.get("workspace_root", _WORKSPACE_ROOT_DEFAULT)
case_dir = workspace_root / f"{chat_size}_{case_id}"
workspace_dir = str(case_dir / ".reme")
if eval_only:
if not case_dir.exists() or not Path(workspace_dir).exists():
raise FileNotFoundError(
f"[Case {case_id}] eval_only: workspace not found at {case_dir}. "
f"Run without --eval_only first to build the workspace.",
)
else:
if case_dir.exists():
shutil.rmtree(case_dir)
logger.info(f"[Case {case_id}] Cleaned existing workspace: {case_dir}")
else:
logger.info(f"[Case {case_id}] Workspace not found, creating: {case_dir}")
case_dir.mkdir(parents=True, exist_ok=True)
# Pre-initialize ReMe's loguru logger with the correct log_dir
output_cfg = eval_config.get("output", {})
if output_cfg.get("log_to_file", False):
reme_log_dir = os.environ.get("REME_LOG_DIR")
if reme_log_dir:
from reme.utils import get_logger
get_logger(
log_dir=reme_log_dir,
level=os.environ.get("REME_LOG_LEVEL", "INFO"),
log_to_console=output_cfg.get("log_to_console", True),
log_to_file=True,
force_init=True,
)
cfg = resolve_app_config(
config=eval_config["reme"]["config"],
workspace_dir=workspace_dir,
log_to_console=output_cfg.get("log_to_console", True),
log_to_file=output_cfg.get("log_to_file", False),
enable_logo=False,
)
app = Application(**cfg)
await app.start()
sessions_ingested = 0
try:
if not eval_only:
# ── Phase 1: Ingest sessions ──────────────────────────────
sessions = load_beam_chat(chat_path, chat_size, case_id)
logger.info(f"[Case {case_id}] Loaded {len(sessions)} sessions from chat.json")
for i, session in enumerate(sessions):
logger.info(
f"[Case {case_id}] Ingesting session {i+1}/{len(sessions)}: "
f"id={session['session_id']} date={session['date']} "
f"msgs={len(session['messages'])}",
)
resp = await app.run_job(
"auto_memory",
messages=session["messages"],
session_id=session["session_id"],
date=session["date"],
)
if not resp.success:
logger.warning(f"[Case {case_id}] auto_memory failed: {resp.answer}")
else:
logger.info(
f"[Case {case_id}] auto_memory success: " f"{resp.answer[:100] if resp.answer else ''}",
)
await app.run_job("index_update")
sessions_ingested += 1
# Final digest update
logger.info(f"[Case {case_id}] Running digest_update...")
await app.run_job("digest_update")
logger.info(f"[Case {case_id}] Ingestion complete.")
# ── Phase 2: Answer + Judge probing questions ───────────────
with open(probing_questions_path, encoding="utf-8") as f:
probing_questions = json.load(f)
total_questions = sum(len(v) for v in probing_questions.values())
logger.info(f"[Case {case_id}] Total probing questions: {total_questions}")
all_question_results = []
q_idx = 0
for q_type in probing_questions:
logger.info(
f"[Case {case_id}] Question type: {q_type} " f"({len(probing_questions[q_type])} questions)",
)
for i, q in enumerate(probing_questions[q_type]):
q_idx += 1
question = q["question"]
rubric = q.get("rubric", [])
logger.info(
f"[Case {case_id}] [{q_idx}/{total_questions}] " f"{q_type} Q{i+1}: {question[:100]}...",
)
q_result = {
"question_type": q_type,
"question_index": i,
"question": question,
"rubric": rubric,
}
# Agentic answer
try:
agentic_answer, agentic_meta = await answer_question_agentic(
app,
question,
)
except Exception as e:
logger.error(f"[Case {case_id}] Agentic answer failed: {e}")
agentic_answer = f"(error: {e})"
agentic_meta = {"error": str(e)}
if not agentic_answer:
agentic_answer = "(no answer generated)"
logger.info(f"[Case {case_id}] Agentic answer: {agentic_answer[:200]}...")
# Judge agentic answer
logger.info(f"[Case {case_id}] Judging agentic ({q_type})...")
agentic_judgment = await judge_answer(
app,
question,
agentic_answer,
rubric,
question_type=q_type,
)
logger.info(
f"[Case {case_id}] Agentic score: " f"{agentic_judgment['llm_judge_score']:.3f}",
)
q_result["agentic_response"] = agentic_answer
q_result["agentic_judgment"] = agentic_judgment
q_result["agentic_metadata"] = agentic_meta
all_question_results.append(q_result)
finally:
await app.close()
return {
"case_id": case_id,
"chat_size": chat_size,
"sessions_ingested": sessions_ingested,
"total_questions": len(all_question_results),
"questions": all_question_results,
}
# ---------------------------------------------------------------------------
# Worker: runs a single case in its own process with its own event loop
# ---------------------------------------------------------------------------
def _evaluate_case_worker(task_input: tuple) -> dict:
"""Worker function for multiprocessing. Each process gets its own event loop."""
eval_config, case_id, log_level, reme_log_level, eval_only, log_dir = task_input
import asyncio # pylint: disable=import-outside-toplevel
_configure_worker(log_level, reme_log_level, log_dir=log_dir)
# Suppress httpx GC noise
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
return asyncio.run(evaluate_case(eval_config, case_id, eval_only=eval_only))
def _indexed_worker(indexed_input: tuple) -> tuple:
"""Module-level wrapper for imap_unordered with index tracking."""
idx, task_input = indexed_input
return idx, _evaluate_case_worker(task_input)
def _resolve_num_workers(configured: int) -> int:
"""Resolve num_workers: 0=auto (cpu_count-2, min 1), 1=sequential, >1=parallel."""
if configured == 0:
return max(1, (os.cpu_count() or 4) - 2)
return max(1, configured)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main( # pylint: disable=too-many-statements
config_path: str | None = None,
log_level: str = "INFO",
reme_log_level: str = "INFO",
eval_only: bool = False,
):
"""Run the BEAM evaluation pipeline.
Args:
config_path: Path to the YAML config file.
log_level: Log level for the eval runner.
reme_log_level: Log level for reme internal logs.
eval_only: If True, skip ingestion and only run query+judge using
existing workspaces.
"""
from multiprocessing import Pool # pylint: disable=import-outside-toplevel
# Load config BEFORE logging setup so log_dir is available
eval_config = load_eval_config(config_path)
# Resolve per-run log directory from config
output_cfg = eval_config.get("output", {})
log_dir_abs = None
if output_cfg.get("log_to_file", False):
log_dir_raw = output_cfg.get("log_dir", "logs")
log_prefix = output_cfg.get("log_prefix", "beam")
run_ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
log_dir_abs = str(_PROJECT_ROOT / log_dir_raw / f"{log_prefix}_{run_ts}")
setup_logging(log_level, reme_log_level, log_dir=log_dir_abs)
dataset_cfg = eval_config["dataset"]
chat_size = dataset_cfg["chat_size"]
beam_root = _PROJECT_ROOT / dataset_cfg.get("beam_root", "benchmark/datasets/BEAM")
# Determine which cases to run
case_ids = dataset_cfg.get("case_ids") or []
if not case_ids:
case_ids = get_available_cases(beam_root, chat_size)
# Pagination
start = dataset_cfg.get("start_index", 0)
num_items = dataset_cfg.get("num_items", 0)
if num_items > 0:
case_ids = case_ids[start : start + num_items]
elif start > 0:
case_ids = case_ids[start:]
if not case_ids:
logger.error(f"No cases found for chat_size={chat_size}")
return
logger.info(
"Evaluating %d case(s) for chat_size=%s: %s%s",
len(case_ids),
chat_size,
case_ids,
" [eval_only: query+judge only]" if eval_only else "",
)
# Resolve parallelism
num_workers = _resolve_num_workers(eval_config["evaluation"].get("num_workers", 1))
logger.info(f"Using {num_workers} worker(s)")
# Create output directory
output_dir = _PROJECT_ROOT / output_cfg.get("dir", "benchmark/results/beam")
output_dir.mkdir(parents=True, exist_ok=True)
# Create workspace root directory
workspace_root = _PROJECT_ROOT / dataset_cfg.get("workspace_root", _WORKSPACE_ROOT_DEFAULT)
workspace_root.mkdir(parents=True, exist_ok=True)
# Pre-check: verify all workspaces exist in eval_only mode
if eval_only:
missing_cases = []
for case_id in case_ids:
case_dir = workspace_root / f"{chat_size}_{case_id}"
if not case_dir.exists() or not (case_dir / ".reme").exists():
missing_cases.append(case_id)
if missing_cases:
preview = missing_cases[:10]
suffix = "..." if len(missing_cases) > 10 else ""
raise FileNotFoundError(
f"eval_only: {len(missing_cases)} workspace(s) not found under {workspace_root}. "
f"Missing cases: {preview}{suffix}. "
f"Run without --eval_only first to build the workspaces.",
)
# Build task args
task_args = [(eval_config, case_id, log_level, reme_log_level, eval_only, log_dir_abs) for case_id in case_ids]
# Progress tracking
total_items = len(task_args)
completed_count = [0]
start_time = time.time()
progress_lock = threading.Lock()
def _print_progress(prefix: str = "PROGRESS"):
elapsed = time.time() - start_time
elapsed_min = elapsed / 60
done = completed_count[0]
pct = 100.0 * done / total_items if total_items else 0
eta_str = "N/A"
if done > 0:
eta_sec = elapsed / done * (total_items - done)
eta_str = f"{eta_sec/60:.1f}min"
print(
f"[{prefix}] {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | "
f"{done}/{total_items} ({pct:.1f}%) completed | "
f"elapsed={elapsed_min:.1f}min | ETA={eta_str}",
flush=True,
)
def _progress_timer():
"""Background thread: print progress every 10 minutes."""
while not _timer_stop.is_set():
_timer_stop.wait(600)
if not _timer_stop.is_set():
with progress_lock:
_print_progress()
_timer_stop = threading.Event()
timer_thread = threading.Thread(target=_progress_timer, daemon=True)
timer_thread.start()
# Run evaluation
if num_workers == 1:
results = []
for task_input in task_args:
result = _evaluate_case_worker(task_input)
results.append(result)
with progress_lock:
completed_count[0] += 1
else:
results = [None] * total_items
indexed_args = list(enumerate(task_args))
with Pool(processes=num_workers) as pool:
for idx, result in pool.imap_unordered(_indexed_worker, indexed_args):
results[idx] = result
with progress_lock:
completed_count[0] += 1
# Stop progress timer
_timer_stop.set()
timer_thread.join(timeout=2)
# Save results
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_file = output_dir / f"results_{chat_size}_{timestamp}.json"
with open(output_file, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
logger.info(f"Results saved to {output_file}")
# Final progress
_print_progress("FINAL")
# Print concise summary
print("\n" + "=" * 70)
print(f" BEAM EVALUATION RESULTS | size={chat_size} cases={len(results)}")
print("=" * 70)
# Per-type stats (agentic only)
type_scores: dict[str, list[float]] = {}
type_binary_scores: dict[str, list[float]] = {}
all_scores: list[float] = []
all_binary_scores: list[float] = []
for case_result in results:
if "error" in case_result:
continue
for q in case_result.get("questions", []):
judgment = q.get("agentic_judgment", {})
score = judgment.get("llm_judge_score", 0.0)
# Binary: convert each rubric item score to 0/1, then average
judge_responses = judgment.get("llm_judge_responses", [])
if judge_responses:
binary_scores_per_item = [1.0 if r.get("score", 0) >= 1.0 else 0.0 for r in judge_responses]
binary_score = sum(binary_scores_per_item) / len(binary_scores_per_item)
else:
binary_score = 1.0 if score > 0.99 else 0.0
qtype = q["question_type"]
if qtype not in type_scores:
type_scores[qtype] = []
type_binary_scores[qtype] = []
type_scores[qtype].append(score)
type_binary_scores[qtype].append(binary_score)
all_scores.append(score)
all_binary_scores.append(binary_score)
print("\n ── AGENTIC ──")
if all_scores:
for qtype in sorted(type_scores.keys()):
scores = type_scores[qtype]
avg = sum(scores) / len(scores) if scores else 0
bin_scores = type_binary_scores[qtype]
bin_avg = sum(bin_scores) / len(bin_scores) if bin_scores else 0
print(f" {qtype:<40s}: {avg:.3f} binary={bin_avg:.3f} ({len(scores)} Qs)")
overall = sum(all_scores) / len(all_scores) if all_scores else 0
binary_overall = sum(all_binary_scores) / len(all_binary_scores) if all_binary_scores else 0
print(f" {'-'*38}")
print(f" {'OVERALL':<40s}: {overall:.3f} binary={binary_overall:.3f} ({len(all_scores)} Qs)")
else:
print(" (no results)")
# Per-case summary
print("\n ── Per-Case Summary ──")
for case_result in results:
case_id = case_result["case_id"]
if "error" in case_result:
print(f" Case {case_id}: ERROR — {case_result['error']}")
continue
n_qs = case_result.get("total_questions", 0)
n_sessions = case_result.get("sessions_ingested", 0)
parts = [f"Case {case_id}: {n_sessions} sessions, {n_qs} questions"]
questions = case_result.get("questions", [])
scores = [q.get("agentic_judgment", {}).get("llm_judge_score", 0.0) for q in questions]
if scores:
avg = sum(scores) / len(scores)
# Binary: 0/1 per rubric item, average per question, then across questions
bin_scores = []
for q in questions:
judge_responses = q.get("agentic_judgment", {}).get("llm_judge_responses", [])
if judge_responses:
item_bins = [1.0 if r.get("score", 0) >= 1.0 else 0.0 for r in judge_responses]
bin_scores.append(sum(item_bins) / len(item_bins))
else:
s = q.get("agentic_judgment", {}).get("llm_judge_score", 0.0)
bin_scores.append(1.0 if s > 0.99 else 0.0)
bin_avg = sum(bin_scores) / len(bin_scores)
parts.append(f"agentic={avg:.3f} binary={bin_avg:.3f}")
print(f" {' | '.join(parts)}")
print("=" * 70)
total_elapsed = time.time() - start_time
print(f"\n Total time: {total_elapsed/60:.1f} min")
print("\n" + "=" * 70)
print(" [DONE] BEAM EVALUATION COMPLETED SUCCESSFULLY")
print("=" * 70 + "\n")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="BEAM evaluation runner")
parser.add_argument("--config", type=str, default=None, help="Path to config.yaml")
parser.add_argument(
"--log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log level for the eval runner (default: INFO)",
)
parser.add_argument(
"--reme-log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log level for reme internal logs — loguru (default: INFO)",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Shortcut for --log-level WARNING --reme-log-level WARNING",
)
parser.add_argument(
"--eval_only",
action="store_true",
help="Skip ingestion. Reuse existing workspaces and only run query+judge.",
)
args = parser.parse_args()
if args.quiet:
args.log_level = "WARNING"
args.reme_log_level = "WARNING"
main(args.config, args.log_level, args.reme_log_level, eval_only=args.eval_only)

View file

@ -0,0 +1,40 @@
# 数据集下载说明
本目录包含 ReMe 评测所需的数据集。部分数据集体积较大,不纳入 Git 版本管理,需要手动下载。
## LongMemEval(cleaned-S)
ReMe 仅使用 LongMemEval 的 **cleaned-S** 版本,数据托管在 HuggingFace:
[agentscope-ai/ReMe_longmemeval_clean_s_v2](https://huggingface.co/datasets/agentscope-ai/ReMe_longmemeval_clean_s_v2)
(下载脚本经 hf-mirror.com 镜像源获取)。
按以下步骤下载:
```bash
cd benchmark/datasets/longmemeval
# 下载 cleaned-S 数据文件(已存在则自动跳过)
python download.py
```
下载完成后,目录下应包含以下文件:
| 文件名 | 说明 |
| --- | --- |
| `longmemeval_s_reme_cleaned.json` | cleaned-S 数据集,已包含 ground truth 字段 |
| `download.py` | 下载脚本(已随仓库提供) |
> **注意**:下载脚本使用 hf-mirror.com 镜像源,如需更换源请修改 `download.py` 中的 `BASE_URL`。
下载完成后即可参照 [`benchmark/README_ZH.md`](../README_ZH.md) 运行 LongMemEval 评测。
## BEAM
BEAM 数据集为公开仓库,直接 clone 到 `benchmark/datasets/` 目录下即可:
```bash
cd benchmark/datasets
git clone https://github.com/mohammadtavakoli78/BEAM.git
```
clone 完成后,`benchmark/datasets/BEAM/` 目录下应包含 `chats/`、`src/`、`topics/` 等子目录。

View file

@ -0,0 +1,40 @@
# Dataset Download Guide
This directory contains datasets required for ReMe evaluation. Some datasets are large and excluded from Git version control — they must be downloaded manually.
## LongMemEval (cleaned-S)
ReMe uses only the **cleaned-S** split of LongMemEval, hosted on HuggingFace:
[agentscope-ai/ReMe_longmemeval_clean_s_v2](https://huggingface.co/datasets/agentscope-ai/ReMe_longmemeval_clean_s_v2)
(the script downloads via the hf-mirror.com mirror).
Download it with:
```bash
cd benchmark/datasets/longmemeval
# Download the cleaned-S data file (skipped automatically if it already exists)
python download.py
```
After downloading, the directory should contain:
| File | Description |
| --- | --- |
| `longmemeval_s_reme_cleaned.json` | cleaned-S dataset with ground truth fields included |
| `download.py` | Download script (included in repo) |
> **Note**: The download script uses hf-mirror.com by default. To use a different mirror, modify `BASE_URL` in `download.py`.
Once the download completes, follow [`benchmark/README.md`](../README.md) to run the LongMemEval evaluation.
## BEAM
BEAM is a public repository. Clone it directly into the `benchmark/datasets/` directory:
```bash
cd benchmark/datasets
git clone https://github.com/mohammadtavakoli78/BEAM.git
```
After cloning, `benchmark/datasets/BEAM/` should contain `chats/`, `src/`, `topics/` and other subdirectories.

View file

@ -0,0 +1,65 @@
"""Download the LongMemEval cleaned-S dataset used by ReMe.
Source: https://huggingface.co/datasets/agentscope-ai/ReMe_longmemeval_clean_s_v2
(downloaded via the hf-mirror.com mirror for reliability).
The file ``longmemeval_s_reme_cleaned.json`` is saved under this directory using the same
name as on the remote (``benchmark/longmemeval/config.yaml`` points to it).
Usage:
python download.py # download cleaned-S (skip if it already exists)
"""
import os
import sys
import urllib.request
BASE_URL = "https://hf-mirror.com/datasets/agentscope-ai/ReMe_longmemeval_clean_s_v2/resolve/main"
TARGET_DIR = os.path.dirname(os.path.abspath(__file__))
# Files to download (saved with the same name as on the remote).
FILES = [
"longmemeval_s_reme_cleaned.json",
]
def download_file(filename: str):
"""Download a single file from the mirror to the target directory."""
url = f"{BASE_URL}/{filename}"
dest = os.path.join(TARGET_DIR, filename)
if os.path.exists(dest):
size = os.path.getsize(dest)
print(f" [skip] {filename} already exists ({size / 1024 / 1024:.1f} MB)")
return
print(f" [downloading] {filename} ...")
try:
urllib.request.urlretrieve(url, dest, reporthook=_progress)
size = os.path.getsize(dest)
print(f"\n [done] {filename} ({size / 1024 / 1024:.1f} MB)")
except Exception as e:
print(f"\n [error] {filename}: {e}")
if os.path.exists(dest):
os.remove(dest)
sys.exit(1)
def _progress(block_num, block_size, total_size):
downloaded = block_num * block_size
if total_size > 0:
pct = min(100, downloaded * 100 / total_size)
mb = downloaded / 1024 / 1024
total_mb = total_size / 1024 / 1024
sys.stdout.write(f"\r {mb:.1f}/{total_mb:.1f} MB ({pct:.1f}%)")
else:
mb = downloaded / 1024 / 1024
sys.stdout.write(f"\r {mb:.1f} MB downloaded")
sys.stdout.flush()
if __name__ == "__main__":
print(f"Downloading LongMemEval cleaned-S dataset to: {TARGET_DIR}\n")
for fname in FILES:
download_file(fname)
print("\nAll files downloaded successfully!")

76
benchmark/kill.sh Normal file
View file

@ -0,0 +1,76 @@
#!/bin/bash
# 杀死指定进程及其所有子进程
# Usage: bash kill.sh <PID>
if [ -z "$1" ]; then
echo "Usage: bash kill.sh <PID>"
echo " 杀死指定进程及其所有子进程"
exit 1
fi
PID=$1
# 检查进程是否存在
if ! kill -0 "$PID" 2>/dev/null; then
echo "进程 $PID 不存在"
exit 1
fi
# 递归收集所有子进程(包括子进程的子进程)
collect_children() {
local parent=$1
local children
children=$(ps -o pid= --ppid "$parent" 2>/dev/null | tr -d ' ')
for child in $children; do
collect_children "$child"
done
echo "$parent"
}
# 收集进程树(子进程在前,父进程在后,保证先杀子再杀父)
PROCESS_TREE=$(collect_children "$PID")
TOTAL=$(echo "$PROCESS_TREE" | wc -l | tr -d ' ')
echo "进程树(共 $TOTAL 个进程):"
while read -r p; do
cmd=$(ps -o args= -p "$p" 2>/dev/null | head -c 80)
printf " PID=%-8s %s\n" "$p" "$cmd"
done <<< "$PROCESS_TREE"
# 先 SIGTERM 优雅终止
echo ""
echo "发送 SIGTERM..."
while read -r p; do
kill "$p" 2>/dev/null
done <<< "$PROCESS_TREE"
# 等待最多 5 秒
for i in $(seq 1 5); do
alive=false
while read -r p; do
if kill -0 "$p" 2>/dev/null; then
alive=true
fi
done <<< "$PROCESS_TREE"
if [ "$alive" = false ]; then
break
fi
sleep 1
done
# 检查是否还有残留,强制 SIGKILL
remaining=false
while read -r p; do
if kill -0 "$p" 2>/dev/null; then
remaining=true
fi
done <<< "$PROCESS_TREE"
if [ "$remaining" = true ]; then
echo "部分进程未响应,发送 SIGKILL..."
while read -r p; do
kill -9 "$p" 2>/dev/null
done <<< "$PROCESS_TREE"
fi
echo "已终止进程树(根 PID=$PID,共 $TOTAL 个进程)"

View file

@ -1,141 +0,0 @@
#!/usr/bin/env python3
"""Remove generated LongMemEval files while keeping source inputs.
For each ``datasets/longmemeval/<idx>`` workspace, this keeps only:
- query.json
- answer.json
- session/
All other files or directories in the sample root are considered generated
artifacts and can be removed. AppleDouble files whose names start with ``._``
are also removed recursively, including under ``session/``. The script is
dry-run by default; pass ``--apply`` to actually delete. To delete only specific
root-level generated files, pass one or more ``--filename`` values.
Examples:
python benchmark/longmemeval/clean_sample_outputs.py
python benchmark/longmemeval/clean_sample_outputs.py --apply
python benchmark/longmemeval/clean_sample_outputs.py --start 36 --end 79 --apply
python benchmark/longmemeval/clean_sample_outputs.py --filename check_golden.json --apply
python benchmark/longmemeval/clean_sample_outputs.py --filename session_review.json --apply
"""
import argparse
import shutil
import time
from collections.abc import Iterator
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
DATA = REPO / "datasets" / "longmemeval"
KEEP = {"query.json", "answer.json", "session"}
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--start", type=int, default=0, help="first numeric sample id to clean, inclusive (default 0)")
p.add_argument("--end", type=int, default=499, help="last numeric sample id to clean, inclusive (default 499)")
p.add_argument("--limit", type=int, default=0, help="only clean the first N selected samples (0 = all)")
p.add_argument("--progress-every", type=int, default=25, help="print progress every N samples when applying")
p.add_argument(
"--filename",
action="append",
default=[],
help="delete only this root-level file or directory name; can be passed multiple times",
)
p.add_argument("--apply", action="store_true", help="actually delete files; default is dry-run")
return p.parse_args()
def sample_ids() -> list[str]:
"""List all numeric sample IDs."""
ids = [p.name for p in DATA.iterdir() if p.is_dir() and p.name.isdigit()]
return sorted(ids, key=int)
def delete_path(path: Path) -> None:
"""Delete a file, symlink, or directory."""
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
def iter_sample_targets(sample_dir: Path, filenames: set[str] | None = None) -> Iterator[Path]:
"""Yield generated artifacts for one sample.
Root-level generated directories are yielded as a whole, so there is no
need to recurse into them. AppleDouble files are only searched inside the
kept ``session/`` directory.
"""
if filenames:
for name in sorted(filenames):
path = sample_dir / name
if path.exists():
yield path
return
for path in sorted(sample_dir.iterdir(), key=lambda p: p.name):
if path.name not in KEEP:
yield path
session_dir = sample_dir / "session"
if session_dir.is_dir():
yield from session_dir.rglob("._*")
def main() -> int:
"""Main entry point."""
args = parse_args()
if args.end < args.start:
raise ValueError(f"--end ({args.end}) must be >= --start ({args.start})")
filenames = {name.strip() for name in args.filename if name.strip()}
invalid_filenames = [name for name in filenames if Path(name).name != name]
if invalid_filenames:
raise ValueError(f"--filename only accepts root-level names, got: {invalid_filenames}")
ids = [idx for idx in sample_ids() if args.start <= int(idx) <= args.end]
if args.limit:
ids = ids[: args.limit]
total_targets = 0
deleted = 0
started_at = time.time()
for ordinal, idx in enumerate(ids, start=1):
sample_dir = DATA / idx
sample_started_at = time.time()
targets = list(iter_sample_targets(sample_dir, filenames=filenames))
total_targets += len(targets)
print(f"[sample {ordinal}/{len(ids)}] {idx} targets={len(targets)}", flush=True)
for path in targets:
if args.apply:
target_started_at = time.time()
print(f"[delete] {path}", flush=True)
delete_path(path)
deleted += 1
print(f"[deleted] {path} elapsed={time.time() - target_started_at:.1f}s", flush=True)
else:
print(f"[would-delete] {path}")
if args.apply and args.progress_every > 0 and (int(idx) + 1) % args.progress_every == 0:
elapsed = time.time() - started_at
print(
f"[progress] processed={ordinal}/{len(ids)} through={idx} " f"deleted={deleted} elapsed={elapsed:.1f}s",
flush=True,
)
print(f"[sample-done] {idx} elapsed={time.time() - sample_started_at:.1f}s", flush=True)
mode = "DELETE" if args.apply else "DRY-RUN"
print(
f"{mode} LongMemEval generated artifacts: samples={len(ids)} "
f"targets={total_targets} deleted={deleted if args.apply else 0} range={args.start}..{args.end}",
flush=True,
)
if not args.apply:
print("No files deleted. Re-run with --apply to delete these paths.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,32 @@
# LongMemEval evaluation configuration
# This file controls what/how to evaluate.
dataset:
path: "benchmark/datasets/longmemeval/longmemeval_s_reme_cleaned.json"
start_index: 0 # first item index
num_items: 500 # how many items to evaluate (starting from start_index)
max_sessions: 0 # 0 = all sessions; >0 = limit sessions per item for testing
question_types: [] # filter by question_type; empty list = no filtering (all types)
workspace_root: "benchmark/memory_workspaces/longmemeval-s" # workspace root for item workspaces
evaluation:
# LLM-as-judge uses the 'judge' as_llm component defined in lme.yaml
# Model and credentials are configured there (reading from .env)
# Judgment is always binary (yes/no) — defined in lme/llm_judge.yaml
num_workers: 32 # 0 = auto (cpu_count - 2, min 1); 1 = sequential; >1 = parallel
filter_future_sessions: true # true = only ingest sessions with timestamp <= question_date
reme:
config: "lme.yaml" # reme config to use (in reme/config/)
# Dream trigger: when gap between consecutive sessions crosses this hour (23:00)
dream_trigger_hour: 23
# Dream scan_days for each trigger
dream_scan_days: 2
dream_max_units: 5
output:
dir: "benchmark/results/longmemeval"
log_dir: "logs" # log directory (relative to project root)
log_prefix: "longmemeval" # benchmark name used in log filenames
log_to_console: true
log_to_file: true

View file

@ -0,0 +1,763 @@
"""LongMemEval evaluation runner for ReMe.
Evaluates ReMe's long-term memory capability using the LongMemEval dataset.
Each item gets an isolated workspace; sessions are ingested in chronological order;
dream is triggered when sessions cross midnight (23:00); finally questions are
answered via an agentic (ReAct) approach and judged by an LLM.
Usage:
python benchmark/longmemeval/run.py
python benchmark/longmemeval/run.py --config benchmark/longmemeval/config.yaml
python benchmark/longmemeval/run.py -q # quiet: only eval-level logs
python benchmark/longmemeval/run.py --log-level WARNING # reduce eval runner logs
python benchmark/longmemeval/run.py --reme-log-level WARNING # reduce reme internal logs
python benchmark/longmemeval/run.py --eval_only # query+judge only, reuse existing workspace
"""
import json
import logging
import os
import re
import shutil
import time
import threading
from datetime import datetime
from pathlib import Path
import yaml
from dotenv import load_dotenv
# Load .env from project root
_PROJECT_ROOT = Path(__file__).parent.parent.parent
load_dotenv(_PROJECT_ROOT / ".env")
# Workspace root for evaluation items — read from config.yaml (dataset.workspace_root)
_WORKSPACE_ROOT_DEFAULT = "benchmark/memory_workspaces/longmemeval-s"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
_DEFAULT_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(message)s"
logging.basicConfig(level=logging.INFO, format=_DEFAULT_LOG_FORMAT)
logger = logging.getLogger("longmemeval")
# Noisy library loggers silenced by default
_NOISY_LOGGERS = [
"httpx",
"httpcore",
"openai",
"uvicorn",
"multipart",
"asyncio",
"watchfiles",
"filelock",
]
def setup_logging(
log_level: str,
reme_log_level: str,
log_dir: str | None = None,
):
"""Configure logging for the eval runner and reme internals.
Args:
log_level: Level for the eval runner logger (DEBUG/INFO/WARNING/ERROR).
reme_log_level: Level for reme's internal loguru logger.
log_dir: Per-run log directory (absolute path). None = no file logging.
"""
numeric = getattr(logging, log_level.upper(), logging.INFO)
# Eval runner logger
logging.getLogger().setLevel(numeric)
logger.setLevel(numeric)
# Suppress noisy library loggers when above DEBUG
if numeric > logging.DEBUG:
for name in _NOISY_LOGGERS:
lib_logger = logging.getLogger(name)
lib_logger.setLevel(max(numeric, logging.WARNING))
# Add file handler for eval runner if log_dir is specified
if log_dir:
os.makedirs(log_dir, exist_ok=True)
log_filepath = os.path.join(log_dir, "runner.log")
file_handler = logging.FileHandler(log_filepath, encoding="utf-8")
file_handler.setLevel(numeric)
file_handler.setFormatter(logging.Formatter(_DEFAULT_LOG_FORMAT))
logging.getLogger().addHandler(file_handler)
logger.info(f"Eval runner log file: {log_filepath}")
# Reme internal logger (loguru) — will be applied per-worker via _configure_worker
os.environ["REME_LOG_LEVEL"] = reme_log_level.upper()
if log_dir:
os.environ["REME_LOG_DIR"] = log_dir
def _configure_worker(
log_level: str,
reme_log_level: str,
log_dir: str | None = None,
):
"""Set up logging inside a multiprocessing worker process.
Must be called at the top of each worker because child processes inherit
parent state but loguru sinks are NOT shared across fork/spawn.
"""
numeric = getattr(logging, log_level.upper(), logging.INFO)
logging.basicConfig(level=numeric, format=_DEFAULT_LOG_FORMAT, force=True)
logging.getLogger("longmemeval").setLevel(numeric)
if numeric > logging.DEBUG:
for name in _NOISY_LOGGERS:
logging.getLogger(name).setLevel(max(numeric, logging.WARNING))
# Add file handler for eval runner in worker process
if log_dir:
os.makedirs(log_dir, exist_ok=True)
pid = os.getpid()
log_filepath = os.path.join(log_dir, f"worker-{pid}.log")
file_handler = logging.FileHandler(log_filepath, encoding="utf-8")
file_handler.setLevel(numeric)
file_handler.setFormatter(logging.Formatter(_DEFAULT_LOG_FORMAT))
logging.getLogger().addHandler(file_handler)
# Re-initialize loguru for reme internals at the desired level
from reme.utils import get_logger
reme_log_dir = log_dir or "logs"
get_logger(log_dir=reme_log_dir, level=reme_log_level.upper(), force_init=True)
# ---------------------------------------------------------------------------
# Config loading
# ---------------------------------------------------------------------------
def load_eval_config(config_path: str | None = None) -> dict:
"""Load evaluation config yaml with env-var expansion."""
if config_path is None:
config_path = str(Path(__file__).parent / "config.yaml")
with open(config_path, encoding="utf-8") as f:
raw = f.read()
# Expand ${VAR} and ${VAR:-default}
def _expand(m):
expr = m.group(1)
if ":-" in expr:
key, default = expr.split(":-", 1)
return os.environ.get(key, default)
return os.environ.get(expr, "")
raw = re.sub(r"\$\{([^}]+)\}", _expand, raw)
return yaml.safe_load(raw)
# ---------------------------------------------------------------------------
# Date utilities
# ---------------------------------------------------------------------------
def parse_haystack_date(date_str: str) -> datetime:
"""Parse LongMemEval date format: '2023/05/20 (Sat) 02:21' -> datetime."""
m = re.match(r"(\d{4}/\d{2}/\d{2})\s+\(\w+\)\s+(\d{2}:\d{2})", date_str)
if not m:
raise ValueError(f"Cannot parse haystack date: {date_str!r}")
return datetime.strptime(f"{m.group(1)} {m.group(2)}", "%Y/%m/%d %H:%M")
def to_iso(dt: datetime) -> str:
"""Convert datetime to ISO-8601 string precise to seconds."""
return dt.strftime("%Y-%m-%dT%H:%M:%S")
def should_trigger_dream(prev_dt: datetime, curr_dt: datetime, _trigger_hour: int = 23) -> bool:
"""Check if the time gap between two sessions crosses trigger_hour (e.g. 23:00)."""
if prev_dt.date() == curr_dt.date():
return False
# There's at least one midnight crossing; check if trigger_hour is between them
# Simple heuristic: if dates differ, dream should run for the previous day
return True
def sessions_sorted_by_time(item: dict) -> list[tuple[int, datetime, str, list[dict]]]:
"""Return (original_index, parsed_datetime, session_id, messages) sorted by time."""
entries = []
for i, (date_str, sid, msgs) in enumerate(
zip(item["haystack_dates"], item["haystack_session_ids"], item["haystack_sessions"]),
):
dt = parse_haystack_date(date_str)
entries.append((i, dt, sid, msgs))
# Sort by time (ascending)
entries.sort(key=lambda x: x[1])
return entries
# ---------------------------------------------------------------------------
# Message formatting
# ---------------------------------------------------------------------------
def format_messages_for_reme(messages: list[dict], session_dt: datetime) -> list[dict]:
"""Convert LongMemEval messages to ReMe auto_memory format.
Adds: name, created_at (ISO seconds). All messages in a session share the
same created_at (the session timestamp).
"""
formatted = []
for msg in messages:
role = msg["role"]
formatted.append(
{
"name": role,
"role": role,
"content": msg["content"],
"created_at": to_iso(session_dt),
},
)
return formatted
# ---------------------------------------------------------------------------
# LLM-as-Judge (delegated to answer_judge_step via app.run_job)
# ---------------------------------------------------------------------------
async def judge_response_via_job(
app,
question: str,
ground_truth: str,
response: str,
question_type: str,
) -> dict:
"""Use the answer_judge_step to evaluate a response against the golden answer."""
judge_resp = await app.run_job(
"answer_judge",
query=question,
agent_answer=response,
golden_answer=ground_truth,
question_type=question_type,
)
verdict = (judge_resp.answer or "").strip().lower()
raw_answer = (judge_resp.metadata or {}).get("raw_answer_judgement", "")
return {
"verdict": verdict,
"reason": raw_answer if verdict not in ("yes", "no") else "",
"metric": "binary",
"question_type": question_type,
}
# ---------------------------------------------------------------------------
# Main evaluation pipeline
# ---------------------------------------------------------------------------
async def evaluate_item(item: dict, eval_config: dict, item_index: int, eval_only: bool = False) -> dict:
"""Evaluate a single LongMemEval item end-to-end.
Args:
item: The dataset item containing question, answer, sessions, etc.
eval_config: The evaluation configuration dict.
item_index: The index of this item in the dataset.
eval_only: If True, skip ingestion (phases 1-3) and only run query+judge
using the existing workspace. Useful for re-evaluating different query
configurations without re-ingesting sessions.
"""
from reme import Application
from reme.config import resolve_app_config
reme_cfg = eval_config["reme"]
dream_trigger_hour = reme_cfg.get("dream_trigger_hour", 23)
dream_scan_days = reme_cfg.get("dream_scan_days", 2)
dream_max_units = reme_cfg.get("dream_max_units", 5)
# Sort sessions by time
sorted_sessions = sessions_sorted_by_time(item)
# Filter out sessions that occur after question_date (if enabled)
filter_future = eval_config["evaluation"].get("filter_future_sessions", True)
if filter_future and item.get("question_date"):
question_dt = parse_haystack_date(item["question_date"])
total_before_filter = len(sorted_sessions)
sorted_sessions = [(i, dt, sid, msgs) for i, dt, sid, msgs in sorted_sessions if dt <= question_dt]
if len(sorted_sessions) < total_before_filter:
logger.info(
f"[Item {item_index}] Filtered sessions: {total_before_filter} -> {len(sorted_sessions)} "
f"(removed {total_before_filter - len(sorted_sessions)} future sessions "
f"after question_date={item['question_date']})",
)
logger.info(
"[Item %s] question_id=%s type=%s sessions=%d%s",
item_index,
item["question_id"],
item["question_type"],
len(sorted_sessions),
" [eval_only]" if eval_only else "",
)
# Use fixed workspace directory (clean it for fresh evaluation)
workspace_root = _PROJECT_ROOT / eval_config["dataset"].get("workspace_root", _WORKSPACE_ROOT_DEFAULT)
item_dir = workspace_root / f"item_{item_index}"
workspace_dir = str(item_dir / ".reme")
if eval_only:
if not item_dir.exists() or not Path(workspace_dir).exists():
raise FileNotFoundError(
f"[Item {item_index}] eval_only: workspace not found at {item_dir}. "
f"Run without --eval_only first to build the workspace.",
)
else:
if item_dir.exists():
shutil.rmtree(item_dir)
logger.info(f"[Item {item_index}] Cleaned existing workspace: {item_dir}")
else:
logger.info(f"[Item {item_index}] Workspace not found, creating: {item_dir}")
item_dir.mkdir(parents=True, exist_ok=True)
# Pre-initialize ReMe's loguru logger with the correct log_dir
# (singleton — Application.__init__ will reuse this instance)
output_cfg = eval_config.get("output", {})
if output_cfg.get("log_to_file", False):
reme_log_dir = os.environ.get("REME_LOG_DIR")
if reme_log_dir:
from reme.utils import get_logger
get_logger(
log_dir=reme_log_dir,
level=os.environ.get("REME_LOG_LEVEL", "INFO"),
log_to_console=output_cfg.get("log_to_console", True),
log_to_file=True,
force_init=True,
)
cfg = resolve_app_config(
config=reme_cfg["config"],
workspace_dir=workspace_dir,
log_to_console=output_cfg.get("log_to_console", True),
log_to_file=output_cfg.get("log_to_file", False),
enable_logo=False,
)
app = Application(**cfg)
await app.start()
try:
dream_dates_triggered = set()
dream_available = True # Set to False if auto_dream job is not found
if not eval_only:
# ── Phase 1: Ingest sessions ──────────────────────────────
prev_dt = None
for idx, (_, session_dt, session_id, messages) in enumerate(sorted_sessions):
# Check if dream should be triggered before this session
if (
dream_available
and prev_dt is not None
and should_trigger_dream(prev_dt, session_dt, dream_trigger_hour)
):
dream_date = prev_dt.strftime("%Y-%m-%d")
if dream_date not in dream_dates_triggered:
logger.info(f"[Item {item_index}] Triggering dream for date={dream_date}")
try:
dream_resp = await app.run_job(
"auto_dream",
date=dream_date,
scan_days=dream_scan_days,
max_units=dream_max_units,
)
logger.info(
f"[Item {item_index}] Dream done: success={dream_resp.success} "
f"answer={dream_resp.answer[:100] if dream_resp.answer else ''}",
)
except Exception as e:
if "not found" in str(e).lower():
dream_available = False
logger.warning(f"[Item {item_index}] auto_dream job not found, skipping all dreams")
else:
logger.warning(f"[Item {item_index}] Dream failed for {dream_date}: {e}")
dream_dates_triggered.add(dream_date)
# Index update after dream to pick up new digest nodes
await app.run_job("index_update")
# Format and ingest the session
formatted_msgs = format_messages_for_reme(messages, session_dt)
date_str = session_dt.strftime("%Y-%m-%d")
logger.info(
f"[Item {item_index}] Ingesting session {idx+1}/{len(sorted_sessions)} "
f"id={session_id} date={date_str} msgs={len(formatted_msgs)}",
)
resp = await app.run_job(
"auto_memory",
messages=formatted_msgs,
session_id=session_id,
date=date_str,
)
if not resp.success:
logger.warning(
f"[Item {item_index}] auto_memory failed for session {session_id}: {resp.answer}",
)
# Manual index update after each session
await app.run_job("index_update")
prev_dt = session_dt
# ── Phase 2: Final dream for the last day ─────────────────
if dream_available and prev_dt is not None:
last_dream_date = prev_dt.strftime("%Y-%m-%d")
if last_dream_date not in dream_dates_triggered:
logger.info(f"[Item {item_index}] Final dream for date={last_dream_date}")
try:
await app.run_job(
"auto_dream",
date=last_dream_date,
scan_days=dream_scan_days,
max_units=dream_max_units,
)
except Exception as e:
if "not found" in str(e).lower():
dream_available = False
logger.warning(f"[Item {item_index}] auto_dream job not found, skipping all dreams")
else:
logger.warning(f"[Item {item_index}] Final dream failed: {e}")
dream_dates_triggered.add(last_dream_date)
# Index update after final dream
await app.run_job("index_update")
# ── Phase 3: Digest update ────────────────────────────────
await app.run_job("digest_update")
# ── Phase 4: Ask question via agentic_answer job (ReAct agent) ──
question = item["question"]
question_date_raw = item.get("question_date", "")
question_dt = parse_haystack_date(question_date_raw) if question_date_raw else None
query_time = to_iso(question_dt) if question_dt else ""
logger.info(
f"[Item {item_index}] Asking (agentic): {question[:80]}... query_time={query_time}",
)
query_resp = await app.run_job(
"agentic_answer",
query=question,
query_time=query_time,
)
agentic_response = (query_resp.answer or "").strip()
if not agentic_response:
agentic_response = "(no answer generated)"
logger.info(f"[Item {item_index}] Agentic response: {agentic_response[:200]}...")
# ── Phase 5: Judge agentic response (via answer_judge_step) ──────────
logger.info(f"[Item {item_index}] Judging agentic (binary, type={item['question_type']})...")
agentic_judgment = await judge_response_via_job(
app=app,
question=question,
ground_truth=item["answer"],
response=agentic_response,
question_type=item["question_type"],
)
logger.info(f"[Item {item_index}] agentic binary result: {agentic_judgment}")
finally:
await app.close()
return {
"question_id": item["question_id"],
"question_type": item["question_type"],
"question": question,
"ground_truth": item["answer"],
"agentic_response": agentic_response,
"agentic_judgment": agentic_judgment,
"sessions_ingested": len(sorted_sessions),
"dreams_triggered": len(dream_dates_triggered),
}
# ---------------------------------------------------------------------------
# Worker: runs a single item in its own process with its own event loop
# ---------------------------------------------------------------------------
def _evaluate_item_worker(task_input: tuple) -> dict:
"""Worker function for multiprocessing. Each process gets its own event loop."""
item, eval_config, item_index, log_level, reme_log_level, eval_only, log_dir = task_input
import asyncio # pylint: disable=import-outside-toplevel
_configure_worker(log_level, reme_log_level, log_dir=log_dir)
# Permanently suppress "Task exception was never retrieved" /
# "Event loop is closed" noise from httpx AsyncClient GC cleanup.
# These fire AFTER asyncio.run() closes the loop, during Python's
# garbage collection of httpx connection-pool tasks — harmless.
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
return asyncio.run(evaluate_item(item, eval_config, item_index, eval_only=eval_only))
def _indexed_worker(indexed_input: tuple) -> tuple:
"""Module-level wrapper for imap_unordered with index tracking."""
idx, task_input = indexed_input
return idx, _evaluate_item_worker(task_input)
def _resolve_num_workers(configured: int) -> int:
"""Resolve num_workers: 0=auto (cpu_count-2, min 1), 1=sequential, >1=parallel."""
if configured == 0:
return max(1, (os.cpu_count() or 4) - 2)
return max(1, configured)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main(
config_path: str | None = None,
log_level: str = "INFO",
reme_log_level: str = "INFO",
eval_only: bool = False,
):
"""Run the LongMemEval evaluation pipeline.
Args:
config_path: Path to the YAML config file.
log_level: Log level for the eval runner.
reme_log_level: Log level for reme internal logs.
eval_only: If True, skip ingestion and only run query+judge using
existing workspaces.
"""
from multiprocessing import Pool # pylint: disable=import-outside-toplevel
# Load config BEFORE logging setup so log_dir is available
eval_config = load_eval_config(config_path)
# Resolve per-run log directory from config
output_cfg = eval_config.get("output", {})
log_dir_abs = None
if output_cfg.get("log_to_file", False):
log_dir_raw = output_cfg.get("log_dir", "logs")
log_prefix = output_cfg.get("log_prefix", "longmemeval")
run_ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
log_dir_abs = str(_PROJECT_ROOT / log_dir_raw / f"{log_prefix}_{run_ts}")
setup_logging(log_level, reme_log_level, log_dir=log_dir_abs)
dataset_cfg = eval_config["dataset"]
# Load dataset
dataset_path = _PROJECT_ROOT / dataset_cfg["path"]
logger.info(f"Loading dataset from {dataset_path}")
with open(dataset_path, encoding="utf-8") as f:
data = json.load(f)
start = dataset_cfg.get("start_index", 0)
num_items = dataset_cfg.get("num_items", 0)
if num_items > 0:
raw_items = data[start : start + num_items]
else:
raw_items = data[start:]
# Build item list
items_with_idx = [(start + i, item) for i, item in enumerate(raw_items)]
# Filter by question_type if specified
question_types = dataset_cfg.get("question_types") or []
if question_types:
before_filter = len(items_with_idx)
items_with_idx = [(idx, item) for idx, item in items_with_idx if item.get("question_type") in question_types]
logger.info(
f"Filtered by question_types={question_types}: {before_filter} -> {len(items_with_idx)} items",
)
logger.info(
"Evaluating %d item(s) starting from index %d%s",
len(items_with_idx),
start,
" [eval_only: query+judge only]" if eval_only else "",
)
# Resolve parallelism
num_workers = _resolve_num_workers(eval_config["evaluation"].get("num_workers", 1))
logger.info(f"Using {num_workers} worker(s)")
# Create output directory
output_dir = _PROJECT_ROOT / output_cfg.get("dir", "benchmark/results/longmemeval")
output_dir.mkdir(parents=True, exist_ok=True)
# Create workspace root directory
workspace_root = _PROJECT_ROOT / dataset_cfg.get("workspace_root", _WORKSPACE_ROOT_DEFAULT)
workspace_root.mkdir(parents=True, exist_ok=True)
# Pre-check: verify all workspaces exist in eval_only mode
if eval_only:
missing_items = []
for orig_idx, _ in items_with_idx:
item_dir = workspace_root / f"item_{orig_idx}"
if not item_dir.exists() or not (item_dir / ".reme").exists():
missing_items.append(orig_idx)
if missing_items:
preview = missing_items[:10]
suffix = "..." if len(missing_items) > 10 else ""
raise FileNotFoundError(
f"eval_only: {len(missing_items)} workspace(s) not found under {workspace_root}. "
f"Missing item indices: {preview}{suffix}. "
f"Run without --eval_only first to build the workspaces.",
)
# Build task args — include log levels, eval_only flag, and log paths (use original index for workspace lookup)
task_args = [
(item, eval_config, orig_idx, log_level, reme_log_level, eval_only, log_dir_abs)
for orig_idx, item in items_with_idx
]
# Progress tracking (force print regardless of log level, every 10 minutes)
total_items = len(task_args)
completed_count = [0] # use list for mutability in closure
start_time = time.time()
progress_lock = threading.Lock()
def _print_progress(prefix: str = "PROGRESS"):
elapsed = time.time() - start_time
elapsed_min = elapsed / 60
done = completed_count[0]
pct = 100.0 * done / total_items if total_items else 0
eta_str = "N/A"
if done > 0:
eta_sec = elapsed / done * (total_items - done)
eta_str = f"{eta_sec/60:.1f}min"
print(
f"[{prefix}] {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | "
f"{done}/{total_items} ({pct:.1f}%) completed | "
f"elapsed={elapsed_min:.1f}min | ETA={eta_str}",
flush=True,
)
def _progress_timer():
"""Background thread: print progress every 10 minutes."""
while not _timer_stop.is_set():
_timer_stop.wait(600) # 10 minutes
if not _timer_stop.is_set():
with progress_lock:
_print_progress()
_timer_stop = threading.Event()
timer_thread = threading.Thread(target=_progress_timer, daemon=True)
timer_thread.start()
# Run evaluation
if num_workers == 1:
# Sequential mode
results = []
for task_input in task_args:
result = _evaluate_item_worker(task_input)
results.append(result)
with progress_lock:
completed_count[0] += 1
else:
# Parallel mode — use imap_unordered for progress tracking
results = [None] * total_items
indexed_args = list(enumerate(task_args))
with Pool(processes=num_workers) as pool:
for idx, result in pool.imap_unordered(_indexed_worker, indexed_args):
results[idx] = result
with progress_lock:
completed_count[0] += 1
# Stop progress timer
_timer_stop.set()
timer_thread.join(timeout=2)
# Save results
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_file = output_dir / f"results_{timestamp}.json"
with open(output_file, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
logger.info(f"Results saved to {output_file}")
# Final progress
_print_progress("FINAL")
_print_summary(results, start_time)
# ---------------------------------------------------------------------------
# Summary printing
# ---------------------------------------------------------------------------
def _print_summary(results: list[dict], start_time: float) -> None:
"""Print per-item verdicts and per-type accuracy."""
print("\n" + "=" * 60)
print("EVALUATION RESULTS")
print("=" * 60)
def _accumulate(judgment_key):
correct = 0
stats: dict = {} # {question_type: {correct: int, total: int}}
for r in results:
qtype = r["question_type"]
verdict = r.get(judgment_key, {}).get("verdict", "N/A")
if qtype not in stats:
stats[qtype] = {"correct": 0, "total": 0}
stats[qtype]["total"] += 1
if verdict == "yes":
correct += 1
stats[qtype]["correct"] += 1
return correct, stats
agentic_correct, agentic_type_stats = _accumulate("agentic_judgment")
total = len(results)
# Per-item verdict rows
for r in results:
a_verdict = r.get("agentic_judgment", {}).get("verdict", "N/A")
print(f" [{r['question_id']}] type={r['question_type']} agentic={a_verdict}")
print("\n" + "-" * 60)
print(f" Items: {total}")
# Agentic stats
print("\n ── Agentic (ReAct) ──")
print(f" Overall accuracy: {agentic_correct}/{total} ({100*agentic_correct/total:.1f}%)")
print(" Per-type accuracy:")
for qtype, stats in sorted(agentic_type_stats.items()):
acc = 100 * stats["correct"] / stats["total"] if stats["total"] else 0
print(f" {qtype}: {stats['correct']}/{stats['total']} ({acc:.1f}%)")
print("=" * 60)
total_elapsed = time.time() - start_time
print(f"\n Total time: {total_elapsed/60:.1f} min")
print("\n" + "=" * 60)
print(" [DONE] EVALUATION COMPLETED SUCCESSFULLY")
print("=" * 60 + "\n")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="LongMemEval evaluation runner")
parser.add_argument("--config", type=str, default=None, help="Path to config.yaml")
parser.add_argument(
"--log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log level for the eval runner (default: INFO)",
)
parser.add_argument(
"--reme-log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log level for reme internal logs — loguru (default: INFO)",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Shortcut for --log-level WARNING --reme-log-level WARNING",
)
parser.add_argument(
"--eval_only",
action="store_true",
help="Skip ingestion (phases 1-3). Reuse existing workspaces and only run query+judge.",
)
args = parser.parse_args()
if args.quiet:
args.log_level = "WARNING"
args.reme_log_level = "WARNING"
main(args.config, args.log_level, args.reme_log_level, eval_only=args.eval_only)

View file

@ -1,343 +0,0 @@
#!/usr/bin/env python3
"""Drive the LongMemEval memory pipeline across all samples.
For every workspace under ``datasets/longmemeval/<idx>`` this launches one or more
``reme start config=jinli_lme job=<job>`` runs with ``LME_WORKSPACE_DIR`` pointed
at that sample. The pipeline jobs, in order, are:
1. auto_memory — distil every raw session into a daily note (``daily/*.md``)
2. update_index — clear the store and rebuild the index over ``daily/*.md``
3. agentic_answer — read ``query.json`` and answer it, writing ``mem_answer.json``
4. llm_judge — judge ``mem_answer.json`` against ``answer.json``
Pick one with ``--job``, or ``--job all`` to run the full pipeline *serially per sample*.
Runs are capped at ``--concurrency`` (default 1 for ``--job auto_memory``, otherwise
3) samples at once and each launch is staggered by ``--stagger`` seconds so they
do not all hit the LLM API at once.
By default every selected job is rerun for every sample — each job's own clear
step (configured in jinli_lme.yaml) wipes stale output first, so a run is always
a clean rebuild. Pass ``--resume`` to instead skip samples whose output already
exists (``daily/`` for auto_memory, ``metadata/embedding_store/`` for
update_index, ``mem_answer.json`` for agentic_answer, ``mem_answer.json`` with
``llm_judge.judgement`` for llm_judge) and continue an interrupted batch. Each
sample's stdout/stderr goes to ``logs/agentic_answer/<job>/<idx>.log``.
After an agentic_answer run finishes, the driver aggregates every sample's query,
golden answer, predicted answer, LLM judgement and a best-effort tool-call trail
into one big JSON at ``logs/agentic_answer/aggregate.json``.
Examples:
python benchmark/longmemeval/run_agentic_answer.py # agentic_answer, all 500, conc 3
python benchmark/longmemeval/run_agentic_answer.py --job all # full pipeline serially per sample
python benchmark/longmemeval/run_agentic_answer.py --job auto_memory # just step 1
python benchmark/longmemeval/run_agentic_answer.py --job llm_judge # just judge existing answers
python benchmark/longmemeval/run_agentic_answer.py --limit 5 --dry-run # list what would run
python benchmark/longmemeval/run_agentic_answer.py --start 187 # samples 187..499
python benchmark/longmemeval/run_agentic_answer.py --start 187 --end 499 # samples 187..499
python benchmark/longmemeval/run_agentic_answer.py --job all --resume # continue an interrupted batch
"""
import argparse
import asyncio
import json
import os
import re
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
DATA = REPO / "datasets" / "longmemeval"
LOGDIR = REPO / "logs" / "agentic_answer"
AGGREGATE = LOGDIR / "aggregate.json"
# Pipeline jobs in execution order.
JOB_ORDER = ["auto_memory", "update_index", "agentic_answer", "llm_judge"]
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument(
"--job",
choices=[*JOB_ORDER, "all"],
default="agentic_answer",
help="which job to run per sample; 'all' runs the full pipeline serially (default: agentic_answer)",
)
p.add_argument("--concurrency", type=int, default=1, help="max samples running at once (default 3)")
p.add_argument("--stagger", type=float, default=1.0, help="seconds between consecutive launches (default 1)")
p.add_argument("--start", type=int, default=0, help="first numeric sample id to process, inclusive (default 0)")
p.add_argument(
"--end",
type=int,
default=0,
help="last numeric sample id to process, inclusive (0 = no upper bound)",
)
p.add_argument("--limit", type=int, default=0, help="only process the first N samples (0 = all)")
p.add_argument(
"--resume",
action="store_true",
help="skip a sample when the job's output already exists (resume an interrupted run); "
"by default every selected job is rerun so the config's clear step rebuilds cleanly",
)
p.add_argument("--dry-run", action="store_true", help="list what would run, launch nothing")
p.add_argument("--no-aggregate", action="store_true", help="skip writing aggregate.json after answer/judge jobs")
return p.parse_args()
def selected_jobs(job: str) -> list[str]:
"""Expand the --job choice into an ordered list of jobs."""
return list(JOB_ORDER) if job == "all" else [job]
def sample_ids() -> list[str]:
"""List all sample IDs (numeric workspace dirs), numerically sorted."""
ids = [p.name for p in DATA.iterdir() if p.is_dir() and p.name.isdigit()]
return sorted(ids, key=int)
def job_done(idx: str, job: str) -> bool:
"""Return True when ``job``'s expected output already exists for sample ``idx``."""
ws = DATA / idx
if job == "auto_memory":
daily = ws / "daily"
return daily.is_dir() and any(daily.rglob("*.md"))
if job == "update_index":
store = ws / "metadata" / "embedding_store"
return store.is_dir() and any(store.iterdir())
if job == "agentic_answer":
return (ws / "mem_answer.json").exists()
if job == "llm_judge":
judge = _load_json(ws / "mem_answer.json").get("llm_judge")
return isinstance(judge, dict) and bool(str(judge.get("judgement") or "").strip())
raise ValueError(f"unknown job: {job}")
async def run_job(idx: str, job: str, counters: dict) -> bool:
"""Run a single job for a single sample. Returns True on success."""
log = LOGDIR / job / f"{idx}.log"
log.parent.mkdir(parents=True, exist_ok=True)
env = dict(os.environ, LME_WORKSPACE_DIR=f"datasets/longmemeval/{idx}")
started = time.strftime("%H:%M:%S")
print(f"[start {started}] {idx}/{job}", flush=True)
with log.open("w", encoding="utf-8") as f:
proc = await asyncio.create_subprocess_exec(
"reme",
"start",
"config=jinli_lme",
f"job={job}",
cwd=str(REPO),
env=env,
stdout=f,
stderr=asyncio.subprocess.STDOUT,
)
rc = await proc.wait()
ok = rc == 0 and job_done(idx, job)
counters["done" if ok else "fail"] += 1
tag = "done" if ok else "fail"
print(f"[{tag}] {idx}/{job} rc={rc} ({counters['done']} done / {counters['fail']} fail)", flush=True)
return ok
async def run_one(idx: str, jobs: list[str], sem: asyncio.Semaphore, resume: bool, counters: dict) -> None:
"""Run the selected jobs for one sample, serially.
By default every selected job is rerun (the job's own clear step wipes stale
output first). With ``resume`` a job is skipped when its output already
exists, so an interrupted batch can continue without redoing finished work.
"""
async with sem:
for job in jobs:
if resume and job_done(idx, job):
counters["skip"] += 1
print(f"[skip] {idx}/{job} (output exists)", flush=True)
continue
ok = await run_job(idx, job, counters)
if not ok:
# Later jobs depend on earlier ones; don't waste a run on a broken workspace.
print(f"[abort] {idx}: {job} failed, skipping remaining jobs", flush=True)
break
# --------------------------------------------------------------------------- #
# Aggregation of agentic_answer results into one big JSON.
# --------------------------------------------------------------------------- #
# Match ``session_id=abc123`` headers and ``"...session_id": "abc123"`` fields in
# tool-result text, so we can list which sessions each search actually surfaced.
_SID_RE = re.compile(r'session_id["\s:=]+"?([A-Za-z0-9_\-]+)')
def _load_json(path: Path) -> dict:
"""Load a JSON object, returning {} on any error."""
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def parse_tool_calls(idx: str, session_id: str) -> list[dict]:
"""Best-effort: parse the agent trajectory into an ordered tool-call summary.
Reads ``mem_session/agentscope/<session_id>.jsonl`` — the trajectory the
agentic_answer run dumped — and pairs every ``tool_call`` (name + parsed
args) with the ``session_id`` hits found in its ``tool_result``. Returns an
empty list if the file is missing or unreadable (never raises).
"""
if not session_id:
return []
path = DATA / idx / "mem_session" / "agentscope" / f"{session_id}.jsonl"
if not path.exists():
return []
calls: dict[str, dict] = {}
order: list[str] = []
try:
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
for c in msg.get("content") or []:
if not isinstance(c, dict):
continue
cid = c.get("id")
if c.get("type") == "tool_call" and cid:
try:
args = json.loads(c.get("input") or "{}")
except (json.JSONDecodeError, TypeError):
args = c.get("input")
calls[cid] = {"name": c.get("name"), "args": args, "hit_session_ids": []}
order.append(cid)
elif c.get("type") == "tool_result" and cid in calls:
text = ""
for o in c.get("output") or []:
if isinstance(o, dict) and isinstance(o.get("text"), str):
text += o["text"]
hits = list(dict.fromkeys(_SID_RE.findall(text)))
calls[cid]["hit_session_ids"] = hits
except OSError:
return []
return [{"iter": i + 1, **calls[cid]} for i, cid in enumerate(order)]
def build_record(idx: str) -> dict:
"""Assemble one sample's aggregate record from its on-disk artifacts."""
ws = DATA / idx
query = _load_json(ws / "query.json")
golden = _load_json(ws / "answer.json")
mem = _load_json(ws / "mem_answer.json")
pred = str(mem.get("answer") or "").strip()
session_id = str(mem.get("session_id") or "")
llm_judge = mem.get("llm_judge") if isinstance(mem.get("llm_judge"), dict) else {}
tool_calls = parse_tool_calls(idx, session_id) if mem else []
if not mem:
status = "missing"
elif not pred:
status = "empty"
elif "not provided" in pred.lower():
status = "not_provided"
else:
status = "answered"
return {
"idx": idx,
"question_id": query.get("question_id"),
"question_type": query.get("question_type"),
"question": query.get("question"),
"question_date": query.get("question_date"),
"golden_answer": golden.get("answer"),
"golden_answer_session_ids": golden.get("answer_session_ids"),
"pred_answer": pred,
"session_id": session_id,
"status": status,
"llm_judge": llm_judge.get("judgement"),
"llm_judge_raw": llm_judge.get("raw_judgement"),
"num_tool_calls": len(tool_calls),
"tool_calls": tool_calls,
}
def write_aggregate(ids: list[str]) -> None:
"""Aggregate every sample's agentic_answer artifacts into one big JSON."""
records = [build_record(idx) for idx in ids]
finished = [r for r in records if r["status"] != "missing"]
by_status: dict[str, int] = {}
by_llm_judge: dict[str, int] = {}
for r in records:
by_status[r["status"]] = by_status.get(r["status"], 0) + 1
judgement = r.get("llm_judge") or "missing"
by_llm_judge[judgement] = by_llm_judge.get(judgement, 0) + 1
payload = {
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"total": len(records),
"finished": len(finished),
"by_status": by_status,
"by_llm_judge": by_llm_judge,
"samples": records,
}
AGGREGATE.parent.mkdir(parents=True, exist_ok=True)
AGGREGATE.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[aggregate] wrote {len(records)} samples ({len(finished)} finished) -> {AGGREGATE}", flush=True)
async def main() -> int:
"""Run the driver."""
args = parse_args()
LOGDIR.mkdir(parents=True, exist_ok=True)
jobs = selected_jobs(args.job)
ids = sample_ids()
if args.end and args.end < args.start:
raise ValueError(f"--end ({args.end}) must be >= --start ({args.start})")
ids = [i for i in ids if int(i) >= args.start and (not args.end or int(i) <= args.end)]
if args.limit:
ids = ids[: args.limit]
# Without --resume every job reruns; with --resume, jobs whose output exists are skipped.
def todo_jobs(i: str) -> list[str]:
return [j for j in jobs if not (args.resume and job_done(i, j))]
pending = [i for i in ids if todo_jobs(i)]
print(
f"jobs={jobs} resume={args.resume} samples total={len(ids)} pending={len(pending)} "
f"concurrency={args.concurrency} stagger={args.stagger}s",
flush=True,
)
if args.dry_run:
for i in pending:
print(f"[would-run] {i}: {todo_jobs(i)}")
return 0
sem = asyncio.Semaphore(args.concurrency)
counters = {"done": 0, "fail": 0, "skip": 0}
tasks: list[asyncio.Task] = []
for n, idx in enumerate(ids):
if n and args.stagger > 0:
await asyncio.sleep(args.stagger) # stagger each launch relative to the previous
tasks.append(asyncio.create_task(run_one(idx, jobs, sem, args.resume, counters)))
await asyncio.gather(*tasks, return_exceptions=True)
print(
f"ALL FINISHED done={counters['done']} fail={counters['fail']} skip={counters['skip']}",
flush=True,
)
if any(j in jobs for j in ("agentic_answer", "llm_judge")) and not args.no_aggregate:
write_aggregate(ids)
return 0 if counters["fail"] == 0 else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View file

@ -1,469 +0,0 @@
#!/usr/bin/env python3
"""Review every LongMemEval golden answer with the configured Claude Code job.
Every numeric ``datasets/longmemeval/<idx>`` workspace is processed sequentially.
The reference JSONL files are merged by ``question_id`` and supplied only when
they contain an alternative answer for that sample:
reme start config=jinli_lme job=final_answer_review
The job returns a plain four-field JSON object with ``reason``,
``golden_answer_correct``, ``answer``, and ``is_session_time_wrong``. After
every new success, this driver atomically rewrites the complete accumulated
output JSONL so an interrupted run can safely resume.
Examples:
python benchmark/longmemeval/run_final_answer_review.py
python benchmark/longmemeval/run_final_answer_review.py --exclude-reference-question-ids
python benchmark/longmemeval/run_final_answer_review.py --only-reference-question-ids --rerun-selected
python benchmark/longmemeval/run_final_answer_review.py --concurrency 2 --submit-interval-seconds 6
python benchmark/longmemeval/run_final_answer_review.py --question-id e47becba
python benchmark/longmemeval/run_final_answer_review.py --reference path/to/results.jsonl
python benchmark/longmemeval/run_final_answer_review.py --limit 3
python benchmark/longmemeval/run_final_answer_review.py --no-resume
python benchmark/longmemeval/run_final_answer_review.py --dry-run
"""
import argparse
import concurrent.futures
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
REPO = Path(__file__).resolve().parents[2]
DATA = REPO / "datasets" / "longmemeval"
DEFAULT_REFERENCES = (
REPO / "benchmark" / "longmemeval" / "golden_check_list_false.jsonl",
REPO / "benchmark" / "longmemeval" / "merge_confirm_jinli_false.jsonl",
)
DEFAULT_OUTPUT = REPO / "benchmark" / "longmemeval" / "final_answer_review.jsonl"
DEFAULT_LOG_DIR = REPO / "logs" / "final_answer_review"
REFERENCE_PATHS_ENV = "LME_FINAL_ANSWER_REFERENCE_PATHS"
MAX_CONCURRENCY = 3
MIN_SUBMIT_INTERVAL_SECONDS = 5.0
DEFAULT_SUBMIT_INTERVAL_SECONDS = 5.1
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--question-id",
dest="question_ids",
action="append",
help="process only this dataset question ID; repeat for multiple IDs (default: all)",
)
reference_selection = parser.add_mutually_exclusive_group()
reference_selection.add_argument(
"--exclude-reference-question-ids",
action="store_true",
help="skip question IDs found in the selected reference-answer JSONL files",
)
reference_selection.add_argument(
"--only-reference-question-ids",
action="store_true",
help="process only question IDs found in the selected reference-answer JSONL files",
)
parser.add_argument(
"--reference",
dest="references",
action="append",
type=Path,
help="reference-answer JSONL; repeat for multiple files (default: built-in disputed results)",
)
parser.add_argument(
"--output",
type=Path,
default=DEFAULT_OUTPUT,
help=f"output JSONL (default: {DEFAULT_OUTPUT})",
)
parser.add_argument(
"--log-dir",
type=Path,
default=DEFAULT_LOG_DIR,
help="directory for per-question logs",
)
parser.add_argument(
"--concurrency",
type=int,
default=MAX_CONCURRENCY,
help=f"maximum concurrent jobs, from 1 to {MAX_CONCURRENCY} (default: {MAX_CONCURRENCY})",
)
parser.add_argument(
"--submit-interval-seconds",
type=float,
default=DEFAULT_SUBMIT_INTERVAL_SECONDS,
help=f"minimum time between job submissions; must be > {MIN_SUBMIT_INTERVAL_SECONDS:g} "
f"(default: {DEFAULT_SUBMIT_INTERVAL_SECONDS:g})",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="process only the first N pending questions (0 = all)",
)
resume_mode = parser.add_mutually_exclusive_group()
resume_mode.add_argument(
"--no-resume",
action="store_true",
help="ignore existing output and rerun every selected question",
)
resume_mode.add_argument(
"--rerun-selected",
action="store_true",
help="rerun every selected question while preserving existing results until replacements finish",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="show the selected cases without invoking ReMe",
)
return parser.parse_args()
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
"""Read a JSONL file and reject malformed or non-object rows."""
rows: list[dict[str, Any]] = []
try:
with path.open(encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
if not line.strip():
continue
try:
row = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON at {path}:{line_number}") from exc
if not isinstance(row, dict):
raise ValueError(f"Expected a JSON object at {path}:{line_number}")
rows.append(row)
except OSError as exc:
raise FileNotFoundError(f"Cannot read JSONL file: {path}") from exc
return rows
def merge_references(paths: list[Path]) -> dict[str, list[dict[str, Any]]]:
"""Merge reference rows by question ID, preserving file and row order."""
merged: dict[str, list[dict[str, Any]]] = {}
seen_sources: set[tuple[str, str]] = set()
for path in paths:
for row in _read_jsonl(path):
question_id = str(row.get("question_id") or "").strip()
if not question_id:
raise ValueError(f"Reference row in {path} has no question_id")
source_key = (question_id, str(path.resolve()))
if source_key in seen_sources:
raise ValueError(f"Duplicate question_id={question_id!r} within {path}")
seen_sources.add(source_key)
merged.setdefault(question_id, []).append({"source": path.name, **row})
if not merged:
raise ValueError("No reference answers found")
return merged
def workspace_map() -> dict[str, Path]:
"""Map every dataset question ID to its numeric sample workspace."""
mapping: dict[str, Path] = {}
for workspace in sorted(
(path for path in DATA.iterdir() if path.is_dir() and path.name.isdigit()),
key=lambda p: int(p.name),
):
query_path = workspace / "query.json"
if not query_path.is_file():
continue
try:
with query_path.open(encoding="utf-8") as file:
query = json.load(file)
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"Cannot parse {query_path}") from exc
if not isinstance(query, dict):
raise ValueError(f"Expected a JSON object in {query_path}")
question_id = str(query.get("question_id") or "").strip()
if not question_id:
raise ValueError(f"Missing question_id in {query_path}")
if question_id in mapping:
raise ValueError(
f"Duplicate dataset question_id={question_id!r}: {mapping[question_id]} and {workspace}",
)
mapping[question_id] = workspace
return mapping
def select_question_ids(
mapping: dict[str, Path],
requested: list[str] | None,
excluded: set[str] | None = None,
) -> list[str]:
"""Return all dataset IDs or validate an explicitly requested subset."""
excluded = excluded or set()
if not requested:
return [question_id for question_id in mapping if question_id not in excluded]
selected: list[str] = []
seen: set[str] = set()
for raw_question_id in requested:
question_id = raw_question_id.strip()
if not question_id:
raise ValueError("--question-id must not be empty")
if question_id in seen:
raise ValueError(f"Duplicate --question-id: {question_id}")
if question_id not in mapping:
raise ValueError(f"No dataset workspace for question ID: {question_id}")
if question_id not in excluded:
selected.append(question_id)
seen.add(question_id)
return selected
def _validate_result(value: Any, *, source: str) -> dict[str, Any]:
"""Validate the final four-field answer contract."""
expected_keys = {"reason", "golden_answer_correct", "answer", "is_session_time_wrong"}
if not isinstance(value, dict) or set(value) != expected_keys:
raise ValueError(
f"{source} must contain exactly 'reason', 'golden_answer_correct', 'answer', "
"and 'is_session_time_wrong'",
)
if not isinstance(value["reason"], str) or not value["reason"].strip():
raise ValueError(f"{source} has an invalid reason")
if not isinstance(value["golden_answer_correct"], bool):
raise ValueError(f"{source} has an invalid golden_answer_correct")
if not isinstance(value["answer"], str):
raise ValueError(f"{source} has an invalid answer")
answer = value["answer"].strip()
if value["golden_answer_correct"] and answer:
raise ValueError(f"{source} answer must be empty when golden_answer_correct is true")
if not value["golden_answer_correct"] and not answer:
raise ValueError(f"{source} answer must be non-empty when golden_answer_correct is false")
if not isinstance(value["is_session_time_wrong"], bool):
raise ValueError(f"{source} has an invalid is_session_time_wrong")
return {
"reason": value["reason"].strip(),
"golden_answer_correct": value["golden_answer_correct"],
"answer": answer,
"is_session_time_wrong": False,
}
def load_existing(path: Path) -> dict[str, dict[str, Any]]:
"""Load resumable output, rejecting duplicate or malformed rows."""
if not path.exists():
return {}
results: dict[str, dict[str, Any]] = {}
for row in _read_jsonl(path):
question_id = str(row.get("question_id") or "").strip()
if not question_id:
raise ValueError(f"Existing output row in {path} has no question_id")
if question_id in results:
raise ValueError(
f"Duplicate question_id={question_id!r} in existing output {path}",
)
results[question_id] = _validate_result(
{key: value for key, value in row.items() if key != "question_id"},
source=f"existing result for {question_id}",
)
return results
def atomic_write_results(
path: Path,
order: list[str],
results: dict[str, dict[str, Any]],
) -> None:
"""Atomically rewrite all accumulated rows in stable merged-input order."""
path.parent.mkdir(parents=True, exist_ok=True)
temp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
dir=path.parent,
prefix=f".{path.name}.",
delete=False,
) as file:
temp_path = Path(file.name)
for question_id in order:
if question_id not in results:
continue
row = {"question_id": question_id, **results[question_id]}
file.write(
json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n",
)
file.flush()
os.fsync(file.fileno())
os.replace(temp_path, path)
finally:
if temp_path is not None and temp_path.exists():
temp_path.unlink()
def run_one(
question_id: str,
workspace: Path,
log_dir: Path,
reference_paths: list[Path],
) -> dict[str, Any]:
"""Run the configured one-shot job and validate its stdout JSON."""
env = dict(os.environ, LME_WORKSPACE_DIR=str(workspace.relative_to(REPO)))
env[REFERENCE_PATHS_ENV] = json.dumps(
[str(path.resolve()) for path in reference_paths],
ensure_ascii=False,
)
completed = subprocess.run(
[
sys.executable,
"-c",
"from reme.reme import main; main()",
"start",
"config=jinli_lme",
"job=final_answer_review",
],
cwd=REPO,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"{question_id}.log"
log_text = (
f"workspace={workspace}\nreturncode={completed.returncode}\n\n"
f"[stdout]\n{completed.stdout}\n[stderr]\n{completed.stderr}"
)
log_path.write_text(
log_text,
encoding="utf-8",
)
if completed.returncode != 0:
raise RuntimeError(
f"Job failed for {question_id} with rc={completed.returncode}; see {log_path}",
)
try:
value = json.loads(completed.stdout.strip())
except json.JSONDecodeError as exc:
raise ValueError(
f"Job stdout is not JSON for {question_id}; see {log_path}",
) from exc
return _validate_result(value, source=f"job result for {question_id}")
def main() -> int:
"""Review and checkpoint the selected dataset cases sequentially."""
args = parse_args()
if args.limit < 0:
raise ValueError("--limit must be >= 0")
if not 1 <= args.concurrency <= MAX_CONCURRENCY:
raise ValueError(f"--concurrency must be between 1 and {MAX_CONCURRENCY}")
if args.submit_interval_seconds <= MIN_SUBMIT_INTERVAL_SECONDS:
raise ValueError(
f"--submit-interval-seconds must be > {MIN_SUBMIT_INTERVAL_SECONDS:g}",
)
reference_paths = [path.resolve() for path in (args.references or DEFAULT_REFERENCES)]
mapping = workspace_map()
references = merge_references(reference_paths)
missing = [question_id for question_id in references if question_id not in mapping]
if missing:
raise ValueError(f"No dataset workspace for question IDs: {', '.join(missing)}")
full_order = list(mapping)
excluded = set(references) if args.exclude_reference_question_ids else set()
order = select_question_ids(mapping, args.question_ids, excluded)
if args.only_reference_question_ids:
order = [question_id for question_id in order if question_id in references]
results = {} if args.no_resume else load_existing(args.output.resolve())
pending = (
list(order) if args.rerun_selected else [question_id for question_id in order if question_id not in results]
)
if args.limit:
pending = pending[: args.limit]
no_reference = sum(question_id not in references for question_id in order)
one_reference = sum(len(references.get(question_id, [])) == 1 for question_id in order)
multiple_references = sum(len(references.get(question_id, [])) > 1 for question_id in order)
print(
f"total={len(order)} no_reference={no_reference} one_reference={one_reference} "
f"multiple_references={multiple_references} "
f"excluded={len(excluded)} "
f"only_reference_questions={args.only_reference_question_ids} "
f"concurrency={args.concurrency} submit_interval={args.submit_interval_seconds:g}s "
f"existing={len(results)} pending={len(pending)} output={args.output.resolve()}",
flush=True,
)
if args.dry_run:
for question_id in pending:
print(
f"[would-run] question_id={question_id} workspace={mapping[question_id].name} "
f"references={len(references.get(question_id, []))}",
)
return 0
executor = concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency)
active: dict[concurrent.futures.Future[dict[str, Any]], tuple[int, str]] = {}
next_position = 0
saved_count = 0
next_submit_at = 0.0
try:
while next_position < len(pending) or active:
can_submit = next_position < len(pending) and len(active) < args.concurrency
if can_submit and time.monotonic() >= next_submit_at:
question_id = pending[next_position]
position = next_position + 1
workspace = mapping[question_id]
print(
f"[submit {position}/{len(pending)}] question_id={question_id} "
f"workspace={workspace.name} references={len(references.get(question_id, []))}",
flush=True,
)
future = executor.submit(
run_one,
question_id,
workspace,
args.log_dir.resolve(),
reference_paths,
)
active[future] = (position, question_id)
next_position += 1
next_submit_at = time.monotonic() + args.submit_interval_seconds
continue
if not active:
time.sleep(max(0.0, next_submit_at - time.monotonic()))
continue
timeout = None
if can_submit:
timeout = max(0.0, next_submit_at - time.monotonic())
done, _ = concurrent.futures.wait(
active,
timeout=timeout,
return_when=concurrent.futures.FIRST_COMPLETED,
)
for future in done:
position, question_id = active.pop(future)
results[question_id] = future.result()
atomic_write_results(args.output.resolve(), full_order, results)
saved_count += 1
print(
f"[saved {saved_count}/{len(pending)}] submitted_position={position} " f"question_id={question_id}",
flush=True,
)
finally:
executor.shutdown(wait=True, cancel_futures=True)
print(
f"ALL FINISHED total_saved={sum(question_id in results for question_id in order)}",
flush=True,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,216 +0,0 @@
#!/usr/bin/env python3
"""Run LongMemEval ``golden_check`` concurrently across samples.
For every workspace under ``datasets/longmemeval/<idx>`` in the selected numeric
range, this launches:
reme start config=jinli_lme job=golden_check
with ``LME_WORKSPACE_DIR`` pointed at that sample. Multiple samples can run at
once, capped by ``--concurrency``. The ``golden_check`` job itself waits for
``session_review.json`` when configured with ``wait_for_paths_step`` in
``jinli_lme.yaml``. Each sample's stdout/stderr goes to
``logs/golden_check/<idx>.log``.
By default the script processes samples 0..499 inclusive and reruns every sample
in that range. Pass ``--resume`` to skip samples whose ``check_golden.json``
already exists.
Examples:
python benchmark/longmemeval/run_golden_check.py
python benchmark/longmemeval/run_golden_check.py --start 187 --end 499
python benchmark/longmemeval/run_golden_check.py --concurrency 8 --stagger 1
python benchmark/longmemeval/run_golden_check.py --progress-interval 10
python benchmark/longmemeval/run_golden_check.py --resume
python benchmark/longmemeval/run_golden_check.py --limit 5 --dry-run
"""
import argparse
import asyncio
import json
import os
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
DATA = REPO / "datasets" / "longmemeval"
LOGDIR = REPO / "logs" / "golden_check"
OUTPUT_FILENAME = "check_golden.json"
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--start", type=int, default=0, help="first numeric sample id to process, inclusive (default 0)")
p.add_argument("--end", type=int, default=499, help="last numeric sample id to process, inclusive (default 499)")
p.add_argument("--limit", type=int, default=0, help="only process the first N selected samples (0 = all)")
p.add_argument("--concurrency", type=int, default=3, help="max samples running at once (default 3)")
p.add_argument("--stagger", type=float, default=1.0, help="seconds between consecutive launches (default 1)")
p.add_argument(
"--progress-interval",
type=float,
default=30.0,
help="seconds between progress reports while running (0 = disabled, default 30)",
)
p.add_argument(
"--resume",
action="store_true",
help=f"skip samples whose {OUTPUT_FILENAME} already exists",
)
p.add_argument("--dry-run", action="store_true", help="list what would run, launch nothing")
return p.parse_args()
def sample_ids() -> list[str]:
"""List all sample IDs (numeric workspace dirs), numerically sorted."""
ids = [p.name for p in DATA.iterdir() if p.is_dir() and p.name.isdigit()]
return sorted(ids, key=int)
def output_is_current(idx: str) -> bool:
"""Return True when the sample already has a current-schema golden-check artifact."""
path = DATA / idx / OUTPUT_FILENAME
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return False
verdict = data.get("verdict") if isinstance(data, dict) else None
if not isinstance(verdict, dict):
return False
return isinstance(verdict.get("golden_answer_correct"), bool) and isinstance(
verdict.get("answer_session_ids_correct"),
bool,
)
def print_progress(counters: dict, active: set[str], selected_total: int, started_at: float) -> None:
"""Print a one-line progress snapshot."""
finished = counters["done"] + counters["fail"] + counters["skip"]
running = len(active)
outstanding = max(selected_total - finished - running, 0)
elapsed = time.monotonic() - started_at
print(
f"[progress] selected={selected_total} done={counters['done']} fail={counters['fail']} "
f"skip={counters['skip']} running={running} outstanding={outstanding} "
f"elapsed={elapsed:.0f}s",
flush=True,
)
async def progress_reporter(
counters: dict,
active: set[str],
selected_total: int,
started_at: float,
interval: float,
stop: asyncio.Event,
) -> None:
"""Periodically report progress until ``stop`` is set."""
if interval <= 0:
return
while not stop.is_set():
try:
await asyncio.wait_for(stop.wait(), timeout=interval)
except asyncio.TimeoutError:
print_progress(counters, active, selected_total, started_at)
async def run_one(idx: str, sem: asyncio.Semaphore, resume: bool, counters: dict, active: set[str]) -> None:
"""Run ``golden_check`` for one sample."""
if resume and output_is_current(idx):
counters["skip"] += 1
print(f"[skip] {idx} ({OUTPUT_FILENAME} exists)", flush=True)
return
async with sem:
active.add(idx)
log = LOGDIR / f"{idx}.log"
log.parent.mkdir(parents=True, exist_ok=True)
env = dict(os.environ, LME_WORKSPACE_DIR=f"datasets/longmemeval/{idx}")
started = time.strftime("%H:%M:%S")
print(f"[start {started}] {idx}", flush=True)
try:
with log.open("w", encoding="utf-8") as f:
proc = await asyncio.create_subprocess_exec(
"reme",
"start",
"config=jinli_lme",
"job=golden_check",
cwd=str(REPO),
env=env,
stdout=f,
stderr=asyncio.subprocess.STDOUT,
)
rc = await proc.wait()
ok = rc == 0 and output_is_current(idx)
counters["done" if ok else "fail"] += 1
tag = "done" if ok else "fail"
print(
f"[{tag}] {idx} rc={rc} log={log} ({counters['done']} done / {counters['fail']} fail)",
flush=True,
)
finally:
active.discard(idx)
async def main() -> int:
"""Run the concurrent driver."""
args = parse_args()
if args.end < args.start:
raise ValueError(f"--end ({args.end}) must be >= --start ({args.start})")
if args.concurrency < 1:
raise ValueError("--concurrency must be >= 1")
if args.progress_interval < 0:
raise ValueError("--progress-interval must be >= 0")
LOGDIR.mkdir(parents=True, exist_ok=True)
ids = [i for i in sample_ids() if args.start <= int(i) <= args.end]
if args.limit:
ids = ids[: args.limit]
pending = [i for i in ids if not (args.resume and output_is_current(i))]
print(
f"job=golden_check samples total={len(ids)} pending={len(pending)} "
f"range={args.start}..{args.end} resume={args.resume} "
f"concurrency={args.concurrency} stagger={args.stagger}s",
flush=True,
)
if args.dry_run:
for idx in pending:
print(f"[would-run] {idx}")
return 0
sem = asyncio.Semaphore(args.concurrency)
counters = {"done": 0, "fail": 0, "skip": 0}
active: set[str] = set()
started_at = time.monotonic()
stop_progress = asyncio.Event()
progress_task = asyncio.create_task(
progress_reporter(counters, active, len(ids), started_at, args.progress_interval, stop_progress),
)
tasks: list[asyncio.Task] = []
try:
for n, idx in enumerate(ids):
if n and args.stagger > 0:
await asyncio.sleep(args.stagger)
tasks.append(asyncio.create_task(run_one(idx, sem, args.resume, counters, active)))
await asyncio.gather(*tasks)
finally:
stop_progress.set()
await progress_task
print_progress(counters, active, len(ids), started_at)
print(
f"ALL FINISHED done={counters['done']} fail={counters['fail']} skip={counters['skip']}",
flush=True,
)
return 0 if counters["fail"] == 0 else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View file

@ -1,203 +0,0 @@
#!/usr/bin/env python3
"""Run LongMemEval ``session_review`` concurrently across samples.
For every workspace under ``datasets/longmemeval/<idx>`` in the selected numeric
range, this launches:
reme start config=jinli_lme job=session_review
with ``LME_WORKSPACE_DIR`` pointed at that sample. Multiple samples can run at
once, capped by ``--concurrency``. By default this runner launches one sample at
a time; request submission is throttled inside each ``session_review`` process.
Each sample's stdout/stderr goes to ``logs/session_review/<idx>.log``.
By default the script processes samples 0..499 inclusive and reruns every sample
in that range. Pass ``--resume`` to skip samples whose ``session_review.json``
already exists.
Examples:
python benchmark/longmemeval/run_session_review.py
python benchmark/longmemeval/run_session_review.py --start 187 --end 499
python benchmark/longmemeval/run_session_review.py --concurrency 2
python benchmark/longmemeval/run_session_review.py --resume
python benchmark/longmemeval/run_session_review.py --limit 5 --dry-run
"""
import argparse
import asyncio
import json
import os
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
DATA = REPO / "datasets" / "longmemeval"
LOGDIR = REPO / "logs" / "session_review"
OUTPUT_FILENAME = "session_review.json"
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--start", type=int, default=0, help="first numeric sample id to process, inclusive (default 0)")
p.add_argument("--end", type=int, default=499, help="last numeric sample id to process, inclusive (default 499)")
p.add_argument("--limit", type=int, default=0, help="only process the first N selected samples (0 = all)")
p.add_argument("--concurrency", type=int, default=1, help="max samples running at once (default 1)")
p.add_argument("--stagger", type=float, default=1.0, help="seconds between worker launches (default 1)")
p.add_argument(
"--resume",
action="store_true",
help=f"skip samples whose {OUTPUT_FILENAME} already exists",
)
p.add_argument("--dry-run", action="store_true", help="list what would run, launch nothing")
p.add_argument("--stop-on-fail", action="store_true", help="stop immediately after the first failed sample")
return p.parse_args()
def sample_ids() -> list[str]:
"""List all sample IDs (numeric workspace dirs), numerically sorted."""
ids = [p.name for p in DATA.iterdir() if p.is_dir() and p.name.isdigit()]
return sorted(ids, key=int)
def output_exists(idx: str) -> bool:
"""Return True when the sample already has a session review artifact."""
return (DATA / idx / OUTPUT_FILENAME).exists()
def output_is_healthy(idx: str) -> bool:
"""Return True when ``session_review.json`` exists and has no failed reviews."""
path = DATA / idx / OUTPUT_FILENAME
if not path.exists():
return False
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return False
review = data.get("review") if isinstance(data, dict) else None
if not isinstance(review, dict):
return False
raw = review.get("num_failed_reviews")
if isinstance(raw, int):
return raw == 0
failed_reviews = review.get("failed_reviews")
return not failed_reviews
async def run_one(idx: str, active: set[str]) -> bool:
"""Run ``session_review`` for one sample. Returns True on success."""
log = LOGDIR / f"{idx}.log"
log.parent.mkdir(parents=True, exist_ok=True)
env = dict(os.environ, LME_WORKSPACE_DIR=f"datasets/longmemeval/{idx}")
started = time.strftime("%H:%M:%S")
print(f"[start {started}] {idx}", flush=True)
active.add(idx)
try:
with log.open("w", encoding="utf-8") as f:
proc = await asyncio.create_subprocess_exec(
"reme",
"start",
"config=jinli_lme",
"job=session_review",
cwd=str(REPO),
env=env,
stdout=f,
stderr=asyncio.subprocess.STDOUT,
)
rc = await proc.wait()
finally:
active.discard(idx)
ok = rc == 0 and output_exists(idx)
tag = "done" if ok else "fail"
print(f"[{tag}] {idx} rc={rc} log={log}", flush=True)
return ok
async def worker(
name: int,
queue: asyncio.Queue[str],
args: argparse.Namespace,
counters: dict[str, int],
active: set[str],
stop: asyncio.Event,
) -> None:
"""Run samples from ``queue`` until exhausted or fail-fast is triggered."""
if name and args.stagger > 0:
await asyncio.sleep(args.stagger * name)
while not stop.is_set():
try:
idx = queue.get_nowait()
except asyncio.QueueEmpty:
return
try:
if args.resume and output_is_healthy(idx):
counters["skip"] += 1
print(f"[skip] {idx} (healthy {OUTPUT_FILENAME} exists)", flush=True)
continue
if await run_one(idx, active):
counters["done"] += 1
else:
counters["fail"] += 1
if args.stop_on_fail:
stop.set()
finally:
queue.task_done()
async def main() -> int:
"""Run the concurrent driver."""
args = parse_args()
if args.end < args.start:
raise ValueError(f"--end ({args.end}) must be >= --start ({args.start})")
if args.concurrency < 1:
raise ValueError("--concurrency must be >= 1")
if args.stagger < 0:
raise ValueError("--stagger must be >= 0")
LOGDIR.mkdir(parents=True, exist_ok=True)
ids = [i for i in sample_ids() if args.start <= int(i) <= args.end]
if args.limit:
ids = ids[: args.limit]
pending = [i for i in ids if not (args.resume and output_exists(i))]
print(
f"job=session_review samples total={len(ids)} pending={len(pending)} "
f"range={args.start}..{args.end} resume={args.resume} "
f"concurrency={args.concurrency} stagger={args.stagger}s",
flush=True,
)
if args.dry_run:
for idx in pending:
print(f"[would-run] {idx}")
return 0
counters: dict[str, int] = {"done": 0, "fail": 0, "skip": 0}
active: set[str] = set()
stop = asyncio.Event()
queue: asyncio.Queue[str] = asyncio.Queue()
for idx in ids:
queue.put_nowait(idx)
workers = [
asyncio.create_task(worker(n, queue, args, counters, active, stop))
for n in range(min(args.concurrency, len(ids)))
]
await asyncio.gather(*workers)
print(
f"ALL FINISHED done={counters['done']} fail={counters['fail']} skip={counters['skip']}",
flush=True,
)
return 0 if counters["fail"] == 0 else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View file

@ -1,202 +0,0 @@
#!/usr/bin/env python3
"""Summarise the ``agentic_answer`` results across all LongMemEval samples.
Reports progress (how many of the 500 samples produced ``mem_answer.json``) and a
breakdown of answer *status*:
- answered — a non-empty answer that is not "not provided";
- not_provided — the agent gave up ("not provided");
- empty — ``mem_answer.json`` exists but the answer is blank;
- missing — no ``mem_answer.json`` yet.
Everything is broken down by ``question_type``. This script does NOT judge answer
correctness (there is no grader for ``mem_answer`` yet) — it only tracks progress
and collects predicted-vs-golden pairs. Tool-call statistics are read from the
aggregate written by ``run_agentic_answer.py`` when it is present.
Examples:
python benchmark/longmemeval/stats_agentic_answer.py
python benchmark/longmemeval/stats_agentic_answer.py --list-run-failed
python benchmark/longmemeval/stats_agentic_answer.py --list-unanswered
python benchmark/longmemeval/stats_agentic_answer.py --json
"""
import argparse
import json
from collections import defaultdict
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
DATA = REPO / "datasets" / "longmemeval"
LOGBASE = REPO / "logs" / "agentic_answer"
AGGREGATE = LOGBASE / "aggregate.json"
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--list-unanswered", action="store_true", help="list samples answered 'not provided' or empty")
p.add_argument("--list-run-failed", action="store_true", help="list launched samples with no readable output")
p.add_argument("--json", action="store_true", help="emit the summary as JSON")
return p.parse_args()
def sample_ids() -> list[str]:
"""List all sample IDs (numeric workspace dirs), numerically sorted."""
ids = [p.name for p in DATA.iterdir() if p.is_dir() and p.name.isdigit()]
return sorted(ids, key=int)
def pct(num: int, den: int) -> str:
"""Format a percentage."""
return f"{(100.0 * num / den):.1f}%" if den else "n/a"
def logged_sample_ids() -> list[str]:
"""List sample IDs that have an agentic_answer launch log."""
logdir = LOGBASE / "agentic_answer"
if not logdir.exists():
return []
ids = [p.stem for p in logdir.glob("*.log") if p.stem.isdigit()]
return sorted(ids, key=int)
def answer_status(pred: str, has_file: bool) -> str:
"""Classify an answer into answered / not_provided / empty / missing."""
if not has_file:
return "missing"
if not pred:
return "empty"
if "not provided" in pred.lower():
return "not_provided"
return "answered"
def load_tool_calls() -> dict[str, int]:
"""Map idx -> num_tool_calls from the aggregate, if it exists."""
if not AGGREGATE.exists():
return {}
try:
with AGGREGATE.open(encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return {}
return {s["idx"]: s.get("num_tool_calls", 0) for s in data.get("samples", []) if "idx" in s}
def main() -> int:
"""Main entry point."""
args = parse_args()
ids = sample_ids()
total = len(ids)
tool_calls = load_tool_calls()
rows, unreadable = [], []
finished_ids = set()
for idx in ids:
query_path = DATA / idx / "query.json"
mem_path = DATA / idx / "mem_answer.json"
qtype = "(unknown)"
try:
with query_path.open(encoding="utf-8") as f:
qtype = json.load(f).get("question_type") or "(unknown)"
except (OSError, json.JSONDecodeError):
pass
has_file = mem_path.exists()
pred = ""
if has_file:
try:
with mem_path.open(encoding="utf-8") as f:
pred = str(json.load(f).get("answer") or "").strip()
finished_ids.add(idx)
except (OSError, json.JSONDecodeError):
unreadable.append(idx)
has_file = False
rows.append({"idx": idx, "type": qtype, "status": answer_status(pred, has_file)})
finished = [r for r in rows if r["status"] != "missing"]
n = len(finished)
launched = logged_sample_ids()
run_failed = [idx for idx in launched if idx not in finished_ids]
# Overall status tallies.
status_counts: dict[str, int] = defaultdict(int)
for r in rows:
status_counts[r["status"]] += 1
answered = status_counts["answered"]
unanswered = [r["idx"] for r in rows if r["status"] in ("not_provided", "empty")]
calls_vals = [tool_calls[i] for i in finished_ids if i in tool_calls]
avg_calls = sum(calls_vals) / len(calls_vals) if calls_vals else 0.0
# Per question_type breakdown.
by_type: dict[str, dict[str, int]] = defaultdict(lambda: {"n": 0, "answered": 0})
for r in finished:
by_type[r["type"]]["n"] += 1
by_type[r["type"]]["answered"] += 1 if r["status"] == "answered" else 0
if args.json:
print(
json.dumps(
{
"total": total,
"finished": n,
"pending": total - n - len(unreadable),
"unreadable": unreadable,
"launched": len(launched),
"run_failed": run_failed,
"status_counts": dict(status_counts),
"answered_rate": round(answered / n, 4) if n else None,
"avg_tool_calls": round(avg_calls, 2) if calls_vals else None,
"by_type": {
t: {**c, "answered_rate": round(c["answered"] / c["n"], 4)} for t, c in by_type.items()
},
"unanswered": unanswered,
"aggregate": str(AGGREGATE) if AGGREGATE.exists() else None,
},
ensure_ascii=False,
indent=2,
),
)
return 0
print("=" * 60)
print("LongMemEval agentic_answer 统计")
print("=" * 60)
print(f"样例总数 : {total}")
print(f"已完成 (有产出) : {n} ({pct(n, total)})")
print(f"未完成 : {total - n - len(unreadable)}")
if unreadable:
print(f"损坏/无法解析 : {len(unreadable)} {unreadable}")
print(f"已启动过 (有 log) : {len(launched)}")
print(f"运行失败/无可读产出 : {len(run_failed)}")
print("-" * 60)
print(f"已作答 (非 not provided): {answered} ({pct(answered, n)} of finished)")
print(f" 其中 not provided : {status_counts['not_provided']}")
print(f" 其中 空答案 : {status_counts['empty']}")
if calls_vals:
print(f"平均工具调用次数 : {avg_calls:.1f} (来自 {AGGREGATE.name})")
else:
print("平均工具调用次数 : n/a (先跑 run_agentic_answer.py 生成 aggregate.json)")
print("-" * 60)
print("按 question_type:")
print(f" {'type':<24} {'n':>4} {'已作答率':>12}")
for t in sorted(by_type):
c = by_type[t]
print(f" {t:<24} {c['n']:>4} {pct(c['answered'], c['n']):>12}")
if args.list_unanswered:
print("-" * 60)
print(f"not provided / 空答案的样例 ({len(unanswered)}): {unanswered}")
if args.list_run_failed:
print("-" * 60)
print(f"运行失败/无可读 mem_answer.json 的样例 ({len(run_failed)}): {run_failed}")
for idx in run_failed:
print(f" {idx}: {LOGBASE / 'agentic_answer' / f'{idx}.log'}")
print("=" * 60)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,344 +0,0 @@
#!/usr/bin/env python3
"""Summarise the ``check_golden.json`` verdicts across all LongMemEval samples.
Reports progress (how many of the 500 samples have finished) and accuracy:
- golden answer accuracy = share of finished samples whose golden answer the
auditor judged correct (``verdict.golden_answer_correct``);
- answer_session_ids accuracy = share whose claimed answer sessions the auditor
judged exactly correct (``verdict.answer_session_ids_correct``).
Everything is also broken down by ``question_type``. Use ``--list-bad`` to print
the samples whose golden answer was judged NOT correct.
Examples:
python benchmark/longmemeval/stats_golden_check.py
python benchmark/longmemeval/stats_golden_check.py --list-bad
python benchmark/longmemeval/stats_golden_check.py --list-run-failed
python benchmark/longmemeval/stats_golden_check.py --json
"""
import argparse
import json
from collections import defaultdict
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
DATA = REPO / "datasets" / "longmemeval"
LOGDIR = REPO / "logs" / "golden_check"
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--list-bad", action="store_true", help="list samples whose golden answer is NOT correct")
p.add_argument(
"--list-bad-sessions",
action="store_true",
help="list samples whose answer_session_ids is NOT correct",
)
p.add_argument(
"--list-run-failed",
action="store_true",
help="list launched samples that did not produce readable output",
)
p.add_argument("--json", action="store_true", help="emit the summary as JSON")
return p.parse_args()
def sample_ids() -> list[str]:
"""List all sample IDs."""
ids = [p.name for p in DATA.iterdir() if p.is_dir() and p.name.isdigit()]
return sorted(ids, key=int)
def pct(num: int, den: int) -> str:
"""Format a percentage."""
return f"{(100.0 * num / den):.1f}%" if den else "n/a"
def logged_sample_ids() -> list[str]:
"""List all sample IDs that have been launched but not finished."""
if not LOGDIR.exists():
return []
ids = [p.stem for p in LOGDIR.glob("*.log") if p.stem.isdigit()]
return sorted(ids, key=int)
def load_json(path: Path) -> dict:
"""Load a JSON object, returning {} on any error."""
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def question_type_for(idx: str, data: dict) -> str:
"""Return question_type from the output, session review, or query.json."""
question_type = str(data.get("question_type") or "").strip()
if question_type:
return question_type
review_path_raw = str(data.get("session_review_path") or "").strip()
review_path = Path(review_path_raw) if review_path_raw else DATA / idx / "session_review.json"
if not review_path.is_absolute():
review_path = REPO / review_path
review = load_json(review_path)
review_question_type = str((review.get("query") or {}).get("question_type") or "").strip()
if review_question_type:
return review_question_type
query = load_json(DATA / idx / "query.json")
return str(query.get("question_type") or "(unknown)").strip() or "(unknown)"
def question_id_for(idx: str, data: dict) -> str:
"""Return question_id from the output, session review, or query.json."""
question_id = str(data.get("question_id") or "").strip()
if question_id:
return question_id
review_path_raw = str(data.get("session_review_path") or "").strip()
review_path = Path(review_path_raw) if review_path_raw else DATA / idx / "session_review.json"
if not review_path.is_absolute():
review_path = REPO / review_path
review = load_json(review_path)
review_question_id = str((review.get("query") or {}).get("question_id") or "").strip()
if review_question_id:
return review_question_id
query = load_json(DATA / idx / "query.json")
return str(query.get("question_id") or "").strip()
def sample_label(data: dict) -> str:
"""Format sample id as idx(question_id) when question_id is available."""
idx = str(data.get("_idx") or "")
qid = str(data.get("_question_id") or "").strip()
return f"{idx}({qid})" if qid else idx
def related_session_ids(data: dict) -> list[str]:
"""Return the best available session ids for a bad verdict record."""
verdict = data.get("verdict") if isinstance(data, dict) else None
if isinstance(verdict, dict):
true_ids = verdict.get("true_answer_session_ids")
if isinstance(true_ids, list):
ids = [str(session_id) for session_id in true_ids if str(session_id).strip()]
if ids:
return ids
summaries = data.get("session_summaries")
if isinstance(summaries, list):
return [
str(summary.get("session_id"))
for summary in summaries
if isinstance(summary, dict) and str(summary.get("session_id") or "").strip()
]
return []
def grouped_records(records: list[dict]) -> dict[str, list[dict]]:
"""Group records by question_type for human-readable list output."""
grouped: dict[str, list[dict]] = defaultdict(list)
for data in records:
question_type = str(data.get("_question_type") or "(unknown)")
grouped[question_type].append(
{
"index": str(data.get("_idx") or ""),
"question_id": str(data.get("_question_id") or ""),
"session_id": related_session_ids(data),
},
)
return dict(sorted(grouped.items()))
def verdict_bool(verdict: dict, new_key: str, old_key: str) -> bool:
"""Read a verdict boolean, accepting the old field name for compatibility."""
if verdict.get(new_key) is True:
return True
if verdict.get(new_key) is False:
return False
return verdict.get(old_key) is True
def has_current_verdict(data: dict) -> bool:
"""Return True when ``check_golden.json`` uses the current golden_check schema."""
verdict = data.get("verdict") if isinstance(data, dict) else None
if not isinstance(verdict, dict):
return False
return isinstance(verdict.get("golden_answer_correct"), bool) and isinstance(
verdict.get("answer_session_ids_correct"),
bool,
)
def write_golden_check_list(done: list[dict], output_path: Path) -> None:
"""Write all readable check_golden records as JSONL."""
with output_path.open("w", encoding="utf-8") as f:
for data in done:
f.write(json.dumps(data, ensure_ascii=False))
f.write("\n")
def main() -> int:
"""Main entry point."""
args = parse_args()
ids = sample_ids()
total = len(ids)
done, unreadable, stale = [], [], []
finished_ids = set()
for idx in ids:
path = DATA / idx / "check_golden.json"
if not path.exists():
continue
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
if not has_current_verdict(data):
stale.append(idx)
continue
data["_idx"] = idx
data["_question_type"] = question_type_for(idx, data)
data["_question_id"] = question_id_for(idx, data)
done.append(data)
finished_ids.add(idx)
except (OSError, json.JSONDecodeError):
unreadable.append(idx)
n = len(done)
output_path = Path.cwd() / "golden_check_list.jsonl"
write_golden_check_list(done, output_path)
launched = logged_sample_ids()
run_failed = [idx for idx in launched if idx not in finished_ids]
# Overall tallies.
golden_ok = sum(
1 for d in done if verdict_bool(d.get("verdict", {}), "golden_answer_correct", "golden_answer_reasonable")
)
sess_ok = sum(
1
for d in done
if verdict_bool(d.get("verdict", {}), "answer_session_ids_correct", "answer_session_ids_reasonable")
)
both_ok = sum(
1
for d in done
if verdict_bool(d.get("verdict", {}), "golden_answer_correct", "golden_answer_reasonable")
and verdict_bool(d.get("verdict", {}), "answer_session_ids_correct", "answer_session_ids_reasonable")
)
# Per question_type breakdown.
by_type: dict[str, dict[str, int]] = defaultdict(lambda: {"n": 0, "golden_ok": 0, "sess_ok": 0, "both_ok": 0})
for d in done:
v = d.get("verdict", {})
golden_is_ok = verdict_bool(v, "golden_answer_correct", "golden_answer_reasonable")
sess_is_ok = verdict_bool(v, "answer_session_ids_correct", "answer_session_ids_reasonable")
t = d.get("_question_type") or "(unknown)"
by_type[t]["n"] += 1
by_type[t]["golden_ok"] += 1 if golden_is_ok else 0
by_type[t]["sess_ok"] += 1 if sess_is_ok else 0
by_type[t]["both_ok"] += 1 if golden_is_ok and sess_is_ok else 0
bad_golden_records = [
d for d in done if not verdict_bool(d.get("verdict", {}), "golden_answer_correct", "golden_answer_reasonable")
]
bad_session_records = [
d
for d in done
if not verdict_bool(d.get("verdict", {}), "answer_session_ids_correct", "answer_session_ids_reasonable")
]
bad_golden = [d["_idx"] for d in bad_golden_records]
bad_sessions = [d["_idx"] for d in bad_session_records]
if args.json:
print(
json.dumps(
{
"total": total,
"finished": n,
"pending": total - n - len(unreadable),
"unreadable": unreadable,
"stale": stale,
"launched": len(launched),
"run_failed": run_failed,
"golden_answer_accuracy": round(golden_ok / n, 4) if n else None,
"answer_session_ids_accuracy": round(sess_ok / n, 4) if n else None,
"both_correct_rate": round(both_ok / n, 4) if n else None,
"golden_ok": golden_ok,
"sess_ok": sess_ok,
"both_ok": both_ok,
"by_type": {
t: {
**c,
"golden_bad": c["n"] - c["golden_ok"],
"session_bad": c["n"] - c["sess_ok"],
"both_bad": c["n"] - c["both_ok"],
"golden_acc": round(c["golden_ok"] / c["n"], 4),
"session_acc": round(c["sess_ok"] / c["n"], 4),
"both_acc": round(c["both_ok"] / c["n"], 4),
}
for t, c in by_type.items()
},
"bad_golden": bad_golden,
"bad_sessions": bad_sessions,
"golden_check_list": str(output_path),
},
ensure_ascii=False,
indent=2,
),
)
return 0
print("=" * 60)
print("LongMemEval golden_check 统计")
print("=" * 60)
print(f"样例总数 : {total}")
print(f"已完成 (有产出) : {n} ({pct(n, total)})")
print(f"未完成 : {total - n - len(unreadable)}")
if unreadable:
print(f"损坏/无法解析 : {len(unreadable)} {unreadable}")
if stale:
print(f"旧格式待重跑 : {len(stale)} {stale}")
print(f"已合并 JSONL : {output_path}")
print(f"已启动过 (有 log) : {len(launched)}")
print(f"运行失败/无可读产出 : {len(run_failed)}")
print("-" * 60)
print(f"golden answer 正确率 : {pct(golden_ok, n)} ({golden_ok}/{n})")
print(f"answer_session 正确率: {pct(sess_ok, n)} ({sess_ok}/{n})")
print(f"两者都正确 : {pct(both_ok, n)} ({both_ok}/{n})")
print("-" * 60)
print("按 question_type:")
print(
f" {'type':<24} {'n':>4} {'golden正确率':>14} {'golden错误':>10} "
f"{'session正确率':>14} {'session错误':>11} {'都正确':>10} {'都正确错误':>12}",
)
for t in sorted(by_type):
c = by_type[t]
print(
f" {t:<24} {c['n']:>4} {pct(c['golden_ok'], c['n']):>14} {c['n'] - c['golden_ok']:>10} "
f"{pct(c['sess_ok'], c['n']):>14} {c['n'] - c['sess_ok']:>11} "
f"{pct(c['both_ok'], c['n']):>10} {c['n'] - c['both_ok']:>12}",
)
if args.list_bad:
print("-" * 60)
print(f"golden answer 判为不正确的样例 ({len(bad_golden_records)}):")
print(json.dumps(grouped_records(bad_golden_records), ensure_ascii=False))
if args.list_bad_sessions:
print("-" * 60)
print(f"answer_session_ids 判为不正确的样例 ({len(bad_session_records)}):")
print(json.dumps(grouped_records(bad_session_records), ensure_ascii=False))
if args.list_run_failed:
print("-" * 60)
print(f"运行失败/无可读 check_golden.json 的样例 ({len(run_failed)}): {run_failed}")
for idx in run_failed:
print(f" {idx}: {LOGDIR / f'{idx}.log'}")
print("=" * 60)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,248 +0,0 @@
#!/usr/bin/env python3
"""Summarise LongMemEval ``session_review.json`` artifacts.
This script is for upstream health checks before running ``golden_check``.
Samples with retryable per-session failures should be rerun as a whole; samples
with non-retryable fallback reviews are reported separately.
Examples:
python benchmark/longmemeval/stats_session_review.py
python benchmark/longmemeval/stats_session_review.py --list-failed
python benchmark/longmemeval/stats_session_review.py --list-fallback
python benchmark/longmemeval/stats_session_review.py --json
"""
import argparse
import json
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
DATA = REPO / "datasets" / "longmemeval"
LOGDIR = REPO / "logs" / "session_review"
OUTPUT_FILENAME = "session_review.json"
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--list-failed", action="store_true", help="list samples with retryable failed per-session reviews")
p.add_argument("--list-fallback", action="store_true", help="list non-retryable fallback reviews")
p.add_argument("--list-missing", action="store_true", help="list samples missing session_review.json")
p.add_argument("--list-run-failed", action="store_true", help="list launched samples without a healthy output")
p.add_argument("--json", action="store_true", help="emit the summary as JSON")
return p.parse_args()
def sample_ids() -> list[str]:
"""List all numeric sample IDs."""
ids = [p.name for p in DATA.iterdir() if p.is_dir() and p.name.isdigit()]
return sorted(ids, key=int)
def pct(num: int, den: int) -> str:
"""Format a percentage."""
return f"{(100.0 * num / den):.1f}%" if den else "n/a"
def load_json(path: Path) -> dict:
"""Load a JSON object, returning {} on any error."""
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def logged_sample_ids() -> list[str]:
"""List sample IDs that have a session_review runner log."""
if not LOGDIR.exists():
return []
ids = [p.stem for p in LOGDIR.glob("*.log") if p.stem.isdigit()]
return sorted(ids, key=int)
def review_block(data: dict) -> dict:
"""Return the review block when present."""
review = data.get("review") if isinstance(data, dict) else None
return review if isinstance(review, dict) else {}
def failure_details(data: dict) -> list[dict]:
"""Return retryable failed_reviews when present."""
failed_reviews = review_block(data).get("failed_reviews")
if not isinstance(failed_reviews, list):
return []
return [item for item in failed_reviews if isinstance(item, dict) and not item.get("fallback")]
def fallback_details(data: dict) -> list[dict]:
"""Return non-retryable fallback review details when present."""
review = review_block(data)
fallback_reviews = review.get("fallback_reviews")
if isinstance(fallback_reviews, list):
return [item for item in fallback_reviews if isinstance(item, dict)]
failed_reviews = review.get("failed_reviews")
if isinstance(failed_reviews, list):
return [item for item in failed_reviews if isinstance(item, dict) and item.get("fallback")]
return []
def failure_count(data: dict) -> int:
"""Return retryable failed review count."""
review = review_block(data)
raw = review.get("num_failed_reviews")
raw_fallback = review.get("num_fallback_reviews")
if isinstance(raw, int) and isinstance(raw_fallback, int):
return max(0, raw - raw_fallback)
return len(failure_details(data))
def fallback_count(data: dict) -> int:
"""Return non-retryable fallback review count."""
review = review_block(data)
raw = review.get("num_fallback_reviews")
if isinstance(raw, int):
return raw
return len(fallback_details(data))
def question_id(data: dict) -> str:
"""Return query.question_id when present."""
query = data.get("query") if isinstance(data, dict) else None
if not isinstance(query, dict):
return ""
return str(query.get("question_id") or "").strip()
def main() -> int:
"""Main entry point."""
args = parse_args()
ids = sample_ids()
total = len(ids)
healthy, failed, fallback, missing, unreadable = [], [], [], [], []
total_failed_sessions = 0
total_fallback_sessions = 0
failed_details_by_id: dict[str, list[dict]] = {}
fallback_details_by_id: dict[str, list[dict]] = {}
question_id_by_id: dict[str, str] = {}
for idx in ids:
path = DATA / idx / OUTPUT_FILENAME
if not path.exists():
missing.append(idx)
continue
data = load_json(path)
if not data:
unreadable.append(idx)
continue
question_id_by_id[idx] = question_id(data)
n_failed = failure_count(data)
n_fallback = fallback_count(data)
if n_failed:
failed.append(idx)
total_failed_sessions += n_failed
failed_details_by_id[idx] = failure_details(data)
if n_fallback:
fallback.append(idx)
total_fallback_sessions += n_fallback
fallback_details_by_id[idx] = fallback_details(data)
if not n_failed:
healthy.append(idx)
launched = logged_sample_ids()
healthy_set = set(healthy)
run_failed = [idx for idx in launched if idx not in healthy_set]
if args.json:
print(
json.dumps(
{
"total": total,
"healthy": len(healthy),
"failed_samples": failed,
"failed_sample_count": len(failed),
"failed_session_count": total_failed_sessions,
"fallback_samples": fallback,
"fallback_sample_count": len(fallback),
"fallback_session_count": total_fallback_sessions,
"missing": missing,
"unreadable": unreadable,
"launched": len(launched),
"run_failed_or_unhealthy": run_failed,
"failed_details": failed_details_by_id,
"fallback_details": fallback_details_by_id,
},
ensure_ascii=False,
indent=2,
),
)
return 0
print("=" * 60)
print("LongMemEval session_review 统计")
print("=" * 60)
print(f"样例总数 : {total}")
print(f"可继续产出 : {len(healthy)} ({pct(len(healthy), total)})")
print(f"有可重试失败 : {len(failed)}")
print(f"可重试失败 session : {total_failed_sessions}")
print(f"有不可重试 fallback : {len(fallback)}")
print(f"fallback session : {total_fallback_sessions}")
print(f"缺少 session_review : {len(missing)}")
print(f"损坏/无法解析 : {len(unreadable)}")
print(f"已启动过 (有 log) : {len(launched)}")
print(f"运行失败/非健康产出 : {len(run_failed)}")
print("-" * 60)
print("有可重试 failed_reviews 的样例需要整体重跑:")
if failed:
print(" ".join(failed))
print("重跑命令示例:")
print(f"python benchmark/longmemeval/run_session_review.py --start {failed[0]} --end {failed[0]}")
else:
print("(none)")
if fallback:
print("-" * 60)
print("不可重试 fallback 的样例不用重跑:")
for idx in fallback:
details = fallback_details_by_id.get(idx) or []
session_ids = [str(item.get("session_id") or "(unknown)") for item in details]
qid = question_id_by_id.get(idx)
sample_label = f"{idx}({qid})" if qid else idx
print(f"{sample_label}: {' '.join(session_ids) if session_ids else '(unknown)'}")
if args.list_failed and failed:
print("-" * 60)
for idx in failed:
details = failed_details_by_id.get(idx) or []
print(f"{idx}: {DATA / idx / OUTPUT_FILENAME} failed_sessions={len(details)}")
for item in details:
session_id = item.get("session_id", "(unknown)")
error = str(item.get("error") or "").replace("\n", " ")
print(f" - {session_id}: {error}")
if args.list_fallback and fallback:
print("-" * 60)
for idx in fallback:
details = fallback_details_by_id.get(idx) or []
print(f"{idx}: {DATA / idx / OUTPUT_FILENAME} fallback_sessions={len(details)}")
for item in details:
session_id = item.get("session_id", "(unknown)")
reason = str(item.get("fallback_reason") or "fallback")
error = str(item.get("error") or "").replace("\n", " ")
raw_saved = "yes" if item.get("raw_session") else "no"
print(f" - {session_id}: reason={reason} raw_session_saved={raw_saved} error={error}")
if args.list_missing and missing:
print("-" * 60)
print(f"缺少 session_review.json 的样例 ({len(missing)}): {missing}")
if args.list_run_failed and run_failed:
print("-" * 60)
print(f"运行失败/非健康产出的样例 ({len(run_failed)}): {run_failed}")
for idx in run_failed:
print(f" {idx}: {LOGDIR / f'{idx}.log'}")
print("=" * 60)
return 0
if __name__ == "__main__":
raise SystemExit(main())

36
benchmark/result-beam.md Normal file
View file

@ -0,0 +1,36 @@
# beam result
## longmemeval版本的prompt
### 100K
| 题型 | Prompted(limit=15) | Agentic |
|---|---|---|
| abstention | 0.525 | 0.575 |
| contradiction_resolution | 0.100 | 0.384 |
| event_ordering | 0.403 | 0.465 |
| information_extraction | 0.618 | 0.884 |
| instruction_following | 0.481 | 0.719 |
| knowledge_update | 0.637 | 0.650 |
| multi_session_reasoning | 0.444 | 0.633 |
| preference_following | 0.706 | 0.829 |
| summarization | 0.423 | 0.617 |
| temporal_reasoning | 0.344 | 0.550 |
| **OVERALL** | **0.468** | **0.631** |
### 1M
| 题型 | Prompted(limit=15) | Prompted Binary | Agentic | Agentic Binary |
|---|---|---|---|---|
| abstention | 0.464 | 0.464 | 0.514 | 0.514 |
| contradiction_resolution | 0.079 | 0.068 | 0.373 | 0.339 |
| event_ordering | 0.455 | 0.334 | 0.547 | 0.450 |
| information_extraction | 0.653 | 0.589 | 0.818 | 0.764 |
| instruction_following | 0.541 | 0.524 | 0.765 | 0.745 |
| knowledge_update | 0.571 | 0.507 | 0.636 | 0.629 |
| multi_session_reasoning | 0.426 | 0.324 | 0.593 | 0.540 |
| preference_following | 0.718 | 0.676 | 0.838 | 0.824 |
| summarization | 0.516 | 0.303 | 0.661 | 0.478 |
| temporal_reasoning | 0.198 | 0.169 | 0.394 | 0.383 |
| **OVERALL** | **0.462** | **0.396** | **0.614** | **0.567** |

View file

@ -0,0 +1,92 @@
# LongMemEval 数据集测试结果
## cleaned-s
**basic settings**
1. 使用修改后的auto-memory prompt,关闭auto-dream机制
2. reme-memory中的全部session的时间一定早于question的时间
**results **
1. Agentic answer框架回答,每次最多调用5次search
| Category | Total | Correct | Wrong | Accuracy |
|---|---|---|---|---|
| single-session-user | 70 | 66 | 4 | 94.3% |
| single-session-assistant | 56 | 52 | 4 | 92.9% |
| knowledge-update | 78 | 60 | 18 | 76.9% |
| multi-session | 133 | 93 | 40 | 69.9% |
| temporal-reasoning | 133 | 78 | 55 | 58.6% |
| single-session-preference | 30 | 8 | 22 | 26.7% |
| **Overall** | **500** | **357** | **143** | **71.4%** |
2. prompted-based amswer,每次固定使用原始query召回10个fileChunk
| Category | Total | Correct | Wrong | Accuracy |
|---|---|---|---|---|
| single-session-assistant | 56 | 56 | 0 | 100.0% |
| single-session-user | 70 | 67 | 3 | 95.7% |
| knowledge-update | 78 | 69 | 9 | 88.5% |
| multi-session | 133 | 99 | 34 | 74.4% |
| temporal-reasoning | 133 | 83 | 50 | 62.4% |
| single-session-preference | 30 | 16 | 14 | 53.3% |
| **Overall** | **500** | **390** | **110** | **78.0%** |
3. golden session。 使用与prompt-based answer相似的方法,唯一区别是,输入的chunk是longMemEval提供的golden session。
| Category | Total | Correct | Wrong | Accuracy |
|---|---|---|---|---|
| single-session-assistant | 56 | 56 | 0 | 100.0% |
| single-session-user | 70 | 69 | 1 | 98.6% |
| knowledge-update | 78 | 74 | 4 | 94.9% |
| temporal-reasoning | 133 | 124 | 9 | 93.2% |
| multi-session | 133 | 117 | 16 | 88.0% |
| single-session-preference | 30 | 17 | 13 | 56.7% |
| **Overall** | **500** | **457** | **43** | **91.4%** |
4. golden session + time filter. 和上面一个实验的区别是,输入的golden被过滤了一次,要求输入session的时间戳必须早于question的时间才行。
一共被过滤掉了75个session,44个question受到了影响。temperal reasoning类型受影响最大。有20个case不包含任何一个groundtruth session。 根据golden session回答正确并且golden session非空,一共有424个case。
| Category | Total | Correct | Wrong | Accuracy |
|---|---|---|---|---|
| knowledge-update | 78 | 75 | 3 | 96.2% |
| single-session-user | 70 | 67 | 3 | 95.7% |
| multi-session | 133 | 122 | 11 | 91.7% |
| single-session-assistant | 56 | 55 | 1 | 98.2% |
| temporal-reasoning | 133 | 91 | 42 | 68.4% |
| single-session-preference | 30 | 16 | 14 | 53.3% |
| **Overall** | **500** | **426** | **74** | **85.2%** |
5. 关闭auto-memory机制,根据原始query一次性混合检索召回原始session,计算recall.
| Category | Total | yes-judge | recall@5 / yes | recall@10 / yes |
|---|---|---|---|---|
| knowledge-update | 78 | 75 | 99.3% | 100% |
| single-session-user | 70 | 67 | 100% | 100% |
| multi-session | 133 | 122 | 91.8% | 95.8% |
| single-session-assistant | 56 | 55 | 100% | 100% |
| temporal-reasoning | 133 | 91 | 87.6% | 94.2% |
| single-session-preference | 30 | 16 | 100% | 100% |
| **Overall** | **500** | **426** | **87.6%** | **94.2%** |
## 最终groundtruth
### agentic + prompted(最终GT,2026-07-16)
| Category | Total | Agentic | Prompted limit=15 |
|---|---|---|---|
| single-session-assistant | 56 | 56/56 (100.0%) | 54/56 (96.4%) |
| single-session-user | 70 | 66/70 (94.3%) | 62/70 (88.6%) |
| knowledge-update | 78 | 75/78 (96.2%) | 67/78 (85.9%) |
| temporal-reasoning | 133 | 122/133 (91.7%) | 117/133 (88.0%) |
| multi-session | 133 | 115/133 (86.5%) | 101/133 (75.9%) |
| single-session-preference | 30 | 21/30 (70.0%) | 10/30 (33.3%) |
| **Overall** | **500** | **455/500 (91.0%)** | **411/500 (82.2%)** |
Prompted token 消耗:总 input 13,111,421 (平均 26,275/题),总 output 313,370 (平均 628/题)。
平均 sessions_ingested: 44.8,dreams_triggered: 0。

586
reme/config/beam.yaml Normal file
View file

@ -0,0 +1,586 @@
# BEAM benchmark config — based on longmemeval.yaml (split)
# All background/cron jobs are converted to base (manually callable).
# Use with: resolve_app_config(config="beam.yaml", ...)
service:
backend: http
jobs:
# ── BEAM agentic answer (ReAct agent + search tool) ──
agentic_answer:
backend: base
description: "BEAM agentic answer job (ReAct agent with search tool)"
watch_dirs: []
watch_suffixes: []
parameters:
type: object
properties:
query:
type: string
description: "The query to ask"
query_time:
type: string
description: "ISO timestamp representing the query time"
default: ""
required:
- query
steps:
- backend: beam_agentic_answer_step
agent_wrapper: bench
# ── BEAM rubric-based LLM-as-Judge ──
answer_judge:
backend: base
description: "BEAM rubric-based LLM-as-Judge: evaluate response against rubric criteria"
watch_dirs: []
watch_suffixes: []
parameters:
type: object
properties:
llm_response:
type: string
description: "The model's response to evaluate"
rubric:
type: array
description: "List of rubric criteria to check"
items:
type: string
probing_question:
type: string
description: "The original probing question"
default: ""
question_type:
type: string
description: "BEAM question type (e.g. event_ordering)"
default: ""
required:
- llm_response
- rubric
steps:
- backend: beam_rubric_judge_step
agent_wrapper: judge
# ── Manual index update (replaces index_update_loop background) ──
index_update:
backend: base
description: "Manually trigger incremental index update for watched dirs."
watch_dirs: [daily_dir, digest_dir, dialog_dir]
watch_suffixes: [md, jsonl]
parameters:
type: object
properties: {}
steps:
- backend: init_changes_step
monitor_type: file_store
monitor_name: default
dispatch_steps: [update_index_step]
# ── Manual digest catalog update (replaces digest_watch_loop background) ──
digest_update:
backend: base
description: "Manually trigger digest catalog update."
watch_dirs: [daily_dir, digest_dir]
watch_suffixes: [md]
parameters:
type: object
properties: {}
steps:
- backend: init_changes_step
monitor_type: file_catalog
monitor_name: digest
dispatch_steps:
- backend: update_catalog_step
file_catalog: digest
- backend: log_changes_step
# ── Auto dream (same as default.yaml auto_dream, base mode) ──
# auto_dream:
# backend: base
# description: "Auto-dream: scan today's day-index and daily notes, globally extract merged units/topics, integrate digest units, write interests.yaml, and persist the dream catalog."
# parameters:
# type: object
# properties:
# date:
# type: string
# description: "YYYY-MM-DD to scan; defaults to today in the dreamer's timezone"
# default: ""
# hint:
# type: string
# description: "caller guidance passed through to dream extract/integrate"
# default: ""
# scan_days:
# type: integer
# description: "number of recent daily directories to scan, ending at date"
# default: 2
# max_units:
# type: integer
# description: "maximum number of extracted memory units"
# default: 5
# topic_count:
# type: integer
# description: "maximum number of final daily interest topics"
# default: 3
# topic_diversity_days:
# type: integer
# description: "number of previous interests.yaml days to avoid repeating"
# default: 7
# steps:
# - backend: dream_extract_step
# file_catalog: dream
# topic_session_id: interests
# scan_days: 2
# max_units: 5
# - backend: dream_integrate_step
# - backend: dream_topics_step
# topic_count: 3
# topic_diversity_days: 7
# - backend: dream_finish_step
# file_catalog: dream
# ── Auto memory (same as default.yaml) ──
auto_memory:
backend: base
description: "Auto-memory: record conversation facts into a daily note"
parameters:
type: object
properties:
messages:
type: array
description: "messages"
items:
type: object
session_id:
type: string
description: "source conversation session identifier"
default: ""
memory_hint:
type: string
description: "optional hint"
date:
type: string
description: "YYYY-MM-DD daily note date; empty = infer from message timestamps or today"
default: ""
required:
- messages
steps:
- backend: beam_auto_memory_step
# ── Reindex (full rebuild) ──
reindex:
backend: base
description: "wipe the file store and rebuild it from the existing files"
watch_dirs: [daily_dir, digest_dir, resource_dir]
watch_suffixes: [md, jsonl]
parameters:
type: object
properties: {}
steps:
- backend: clear_store_step
- backend: init_changes_step
monitor_type: file_store
monitor_name: default
dispatch_steps: [update_index_step]
# ── Search ──
# start_date:
# type: string
# description: "optional inclusive start date filter (YYYY-MM-DD); results earlier than this date are excluded"
# end_date:
# type: string
# description: "optional inclusive end date filter (YYYY-MM-DD); results later than this date are excluded"
search:
backend: base
description: "Hybrid workspace search (vector + BM25, RRF-fused) with deduplication."
parameters:
type: object
properties:
query:
type: string
description: "search query"
start_date:
type: string
description: "optional inclusive start date filter (YYYY-MM-DD); results earlier than this date are excluded"
end_date:
type: string
description: "optional inclusive end date filter (YYYY-MM-DD); results later than this date are excluded"
# vector_weight:
# type: number
# description: >-
# Optional weight balancing vector similarity vs BM25 keyword matching in the
# RRF fusion. Recommended value is 0.7, which provides a good balance between
# semantic (vector) similarity and lexical (BM25) matching. Values close to 0
# emphasize BM25 keyword matching, values close to 1 emphasize vector semantic
# similarity.
required:
- query
steps:
- backend: search_step
vector_weight: 0.7
candidate_multiplier: 5.0
expand_links: true
max_links_per_direction: 10
add_draft:
backend: base
description: "Append text to the current draft list."
parameters:
type: object
properties:
text:
type: string
description: "draft text to append"
required:
- text
steps:
- backend: add_draft_step
read_all_draft:
backend: base
description: "Read all draft text previously appended in the current tool context."
parameters:
type: object
properties: { }
steps:
- backend: read_all_draft_step
python_execute:
backend: base
description: "Execute Python code and return printed stdout."
parameters:
type: object
properties:
code:
type: string
description: "Python code to execute. Print the final result to stdout."
timeout:
type: number
description: "Execution timeout in seconds; defaults to 60."
required:
- code
steps:
- backend: python_execute_step
# ── File I/O jobs (needed by auto_memory agent tools) ──
daily_list:
backend: base
description: "List notes under a single day."
parameters:
type: object
properties:
date:
type: string
description: "YYYY-MM-DD; empty = today"
default: ""
steps:
- backend: daily_list_step
daily_reindex:
backend: base
description: "Rebuild the day-index page daily/<date>.md."
parameters:
type: object
properties:
date:
type: string
description: "YYYY-MM-DD; empty = today"
default: ""
steps:
- backend: daily_reindex_step
frontmatter_update:
backend: base
description: "Merge key-values into a file's frontmatter."
parameters:
type: object
properties:
path:
type: string
description: "workspace-relative path"
metadata:
type: object
description: "key-values to merge"
required:
- path
- metadata
steps:
- backend: frontmatter_update_step
move:
backend: base
description: "Move / rename a workspace file."
parameters:
type: object
properties:
src_path:
type: string
description: "workspace-relative source"
dst_path:
type: string
description: "workspace-relative destination"
overwrite:
type: boolean
default: false
retarget:
type: boolean
default: true
required:
- src_path
- dst_path
steps:
- backend: move_step
read:
backend: base
description: "Read a markdown file under the workspace."
parameters:
type: object
properties:
path:
type: string
description: "workspace-relative path; markdown only"
start_line:
type: integer
end_line:
type: integer
required:
- path
steps:
- backend: read_step
with_neighbors: false
max_neighbors_per_direction: 10
write:
backend: base
description: "Write a markdown file."
parameters:
type: object
properties:
path:
type: string
name:
type: string
description:
type: string
content:
type: string
metadata:
type: object
required:
- path
- name
- description
- content
steps:
- backend: write_step
daily_write:
backend: base
description: "Write a daily markdown note."
parameters:
type: object
properties:
name:
type: string
description:
type: string
session_id:
type: string
content:
type: string
date:
type: string
default: ""
metadata:
type: object
required:
- name
- description
- session_id
- content
steps:
- backend: daily_write_step
edit:
backend: base
description: "Find-and-replace in a markdown file."
parameters:
type: object
properties:
path:
type: string
old:
type: string
new:
type: string
default: ""
required:
- path
- old
- new
steps:
- backend: edit_step
frontmatter_read:
backend: base
description: "Read a file's frontmatter as a dict."
parameters:
type: object
properties:
path:
type: string
required:
- path
steps:
- backend: frontmatter_read_step
node_search:
backend: base
description: "Digest node recall."
parameters:
type: object
properties:
query:
type: string
limit:
type: integer
default: 20
required:
- query
steps:
- backend: node_search_step
vector_weight: 0.7
candidate_multiplier: 5.0
components:
tokenizer:
default:
backend: regex
as_embedding:
default:
backend: ${EMBEDDING_BACKEND:-openai}
model: ${EMBEDDING_MODEL_NAME:-text-embedding-v4}
credential:
api_key: ${EMBEDDING_API_KEY:-}
base_url: ${EMBEDDING_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1}
dimensions: 1024
embedding_store:
default:
backend: local
as_embedding: default
as_llm:
default:
backend: ${LLM_BACKEND:-openai}
model: ${LLM_MODEL_NAME:-qwen3.6-flash}
stream: true
context_size: 200000
max_retries: 5
retry_delay: 5.0
credential:
api_key: ${LLM_API_KEY:-}
base_url: ${LLM_BASE_URL:-}
parameters:
max_tokens: 65536
thinking_enable: false
judge:
backend: ${LLM_BACKEND:-openai}
model: ${JUDGE_MODEL_NAME:-qwen3.7-max}
stream: false
context_size: 200000
max_retries: 5
retry_delay: 5.0
credential:
api_key: ${LLM_API_KEY:-}
base_url: ${LLM_BASE_URL:-}
parameters:
max_tokens: 65536
thinking_enable: false
bench:
backend: ${LLM_BACKEND:-openai}
model: ${BENCH_MODEL_NAME:-qwen3.7-max}
stream: true
context_size: 400000
max_retries: 5
retry_delay: 5.0
credential:
api_key: ${LLM_API_KEY:-}
base_url: ${LLM_BASE_URL:-}
parameters:
max_tokens: 65536
thinking_enable: true
agent_wrapper:
default:
backend: agentscope
as_llm: default
permission_mode: bypass
react_config:
max_iters: 30
context_config:
trigger_ratio: 0.8
reserve_ratio: 0.1
tool_result_limit: 50000
model_config:
max_retries: 1
judge:
backend: agentscope
as_llm: judge
permission_mode: bypass
react_config:
max_iters: 1
context_config:
trigger_ratio: 0.8
reserve_ratio: 0.1
tool_result_limit: 50000
model_config:
max_retries: 1
bench:
backend: agentscope
as_llm: bench
permission_mode: bypass
react_config:
max_iters: 30
context_config:
trigger_ratio: 0.8
reserve_ratio: 0.1
tool_result_limit: 50000
model_config:
max_retries: 1
file_graph:
default:
backend: local
file_catalog:
default:
backend: local
resource:
backend: local
digest:
backend: local
dream:
backend: local
file_chunker:
markdown:
backend: markdown
supported_extensions: ["md"]
default:
backend: default
supported_extensions: ["jsonl"]
keyword_index:
default:
backend: bm25
tokenizer: default
file_store:
default:
backend: local
store_name: local
embedding_store: default
keyword_index: default
file_graph: default

View file

@ -1,419 +0,0 @@
service:
backend: cli
workspace_dir: ${LME_WORKSPACE_DIR:-datasets/longmemeval/1}
session_dir: history_session
resource_dir: session
daily_dir: daily
digest_dir: ""
jobs:
auto_memory:
backend: base
description: "Extract every raw session into a search-friendly daily note (one note per session)."
parameters:
type: object
properties: { }
steps:
- backend: clear_paths_step # wipe old daily notes so this is a clean rebuild
config_keys: [daily_dir]
- backend: lme_auto_memory_step
agent_wrapper: lme_memory
update_index:
backend: base
watch_dirs: [daily_dir]
watch_suffixes: [md]
steps:
- backend: clear_store_step
- backend: init_changes_step
monitor_type: file_store
monitor_name: default
dispatch_steps: [update_index_step]
extract_session_by_id:
backend: base
description: "Given a session_id shown in a search result, go back to the original raw session and extract everything in it that is relevant to the current question. Use this when a search hit looks relevant but the distilled note lacks an exact number, date, or wording."
parameters:
type: object
properties:
session_id:
type: string
description: "the session_id from a search result header"
required:
- session_id
steps:
- backend: lme_extract_session_step
agent_wrapper: lme_extract
vector_search:
backend: base
description: "Dense semantic search over the memory notes. Returns each hit with its source note path and session_id."
parameters:
type: object
properties:
query:
type: string
description: "search query"
required:
- query
steps:
- backend: vector_search_step
include_source: true
bm25_search:
backend: base
description: "Keyword (BM25) search over the memory notes. Returns each hit with its source note path and session_id."
parameters:
type: object
properties:
query:
type: string
description: "search query"
required:
- query
steps:
- backend: bm25_search_step
include_source: true
agentic_answer:
backend: base
description: "Answer query.json from indexed memory using vector/bm25 search + session extraction."
parameters:
type: object
properties: { }
steps:
- backend: clear_paths_step # drop the previous answer so each run rewrites cleanly
paths: [mem_answer.json]
- backend: lme_agentic_answer_step
agent_wrapper: lme_agentic_answer
llm_judge:
backend: base
description: "Judge mem_answer.json against answer.json and write the judgement back into mem_answer.json."
parameters:
type: object
properties: { }
steps:
- backend: lme_llm_judge_step
agent_wrapper: lme_judge
version:
backend: base
description: "return reme package version"
parameters:
type: object
properties: { }
steps:
- backend: version_step
search:
backend: base
description: "Hybrid workspace search (vector + BM25, RRF-fused)."
parameters:
type: object
properties:
query:
type: string
description: "search query"
start_date:
type: string
description: "optional inclusive start date filter (YYYY-MM-DD); results earlier than this date are excluded"
end_date:
type: string
description: "optional inclusive end date filter (YYYY-MM-DD); results later than this date are excluded"
required:
- query
steps:
- backend: search_step
vector_weight: 0.7
candidate_multiplier: 5.0
expand_links: true
max_links_per_direction: 10
add_draft:
backend: base
description: "Append text to the current draft list."
parameters:
type: object
properties:
text:
type: string
description: "draft text to append"
required:
- text
steps:
- backend: add_draft_step
read_all_draft:
backend: base
description: "Read all draft text previously appended in the current tool context."
parameters:
type: object
properties: { }
steps:
- backend: read_all_draft_step
python_execute:
backend: base
description: "Execute Python code and return printed stdout."
parameters:
type: object
properties:
code:
type: string
description: "Python code to execute. Print the final result to stdout."
timeout:
type: number
description: "Execution timeout in seconds; defaults to 60."
required:
- code
steps:
- backend: python_execute_step
session_review:
backend: base
description: "Review every session for query/answer-relevant evidence and write session_review.json."
parameters:
type: object
properties: { }
steps:
- backend: clear_paths_step # drop the previous review so each run rewrites cleanly
paths: [session_review.json]
- backend: lme_session_review_step
agent_wrapper: lme_review
golden_check:
backend: base
description: "Read session_review.json and judge whether the golden answer is reasonable."
parameters:
type: object
properties: { }
steps:
- backend: clear_paths_step # drop the previous verdict so each run rewrites cleanly
paths: [check_golden.json]
- backend: wait_for_paths_step # wait until session_review finishes; comment this step to fail fast instead
paths: [session_review.json]
poll_seconds: 5
log_every_seconds: 60
- backend: lme_golden_check_step
agent_wrapper: lme_judge
final_answer_review:
backend: base
description: "Review one LongMemEval golden answer from all sessions available by question_date."
parameters:
type: object
properties: { }
steps:
- backend: lme_final_answer_review_step
agent_wrapper: lme_final_answer_review
reference_paths:
- benchmark/longmemeval/golden_check_list_false.jsonl
- benchmark/longmemeval/merge_confirm_jinli_false.jsonl
retry_initial_seconds: 5
retry_max_seconds: 300
components:
tokenizer:
default:
backend: regex
as_embedding:
default:
backend: ${EMBEDDING_BACKEND:-openai}
model: ${EMBEDDING_MODEL_NAME:-text-embedding-v4}
dimensions: 1024
credential:
api_key: ${EMBEDDING_API_KEY:-}
base_url: ${EMBEDDING_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1}
parameters: { }
embedding_store:
default:
backend: local
as_embedding: default
as_llm:
default:
backend: ${LLM_BACKEND:-openai}
model: ${LLM_MODEL_NAME:-qwen3.7-max}
stream: true
context_size: 1000000
max_retries: 3
credential:
api_key: ${LLM_API_KEY:-}
base_url: ${LLM_BASE_URL:-}
parameters:
max_tokens: 65536
plus:
backend: ${LLM_BACKEND:-openai}
model: qwen3.7-plus
stream: true
context_size: 1000000
max_retries: 3
credential:
api_key: ${LLM_API_KEY:-}
base_url: ${LLM_BASE_URL:-}
parameters:
max_tokens: 65536
agent_wrapper:
default:
backend: agentscope
as_llm: default
permission_mode: bypass
react_config:
max_iters: 30
context_config:
trigger_ratio: 0.89
reserve_ratio: 0.1
tool_result_limit: 50000
model_config:
max_retries: 3
agentic_search_agentwrapper:
backend: agentscope
as_llm: default
cwd: session
permission_mode: bypass
builtin_tools: false
job_tools:
- search
- add_draft
- read_all_draft
- python_execute
sequential_tool_calls: true
react_config:
max_iters: 100
context_config:
trigger_ratio: 0.89
reserve_ratio: 0.1
tool_result_limit: 1000000
model_config:
max_retries: 3
lme_memory:
backend: agentscope
as_llm: plus
permission_mode: bypass
builtin_tools: false
react_config:
max_iters: 8
context_config:
trigger_ratio: 0.89
reserve_ratio: 0.1
tool_result_limit: 1000000
model_config:
max_retries: 3
lme_extract:
backend: agentscope
as_llm: plus
permission_mode: bypass
builtin_tools: false
react_config:
max_iters: 3
context_config:
trigger_ratio: 0.89
reserve_ratio: 0.1
tool_result_limit: 1000000
model_config:
max_retries: 3
lme_agentic_answer:
backend: agentscope
as_llm: default
cwd: session
permission_mode: bypass
builtin_tools: false
job_tools:
- vector_search
- bm25_search
- python_execute
- extract_session_by_id
sequential_tool_calls: true
react_config:
max_iters: 40
context_config:
trigger_ratio: 0.89
reserve_ratio: 0.1
tool_result_limit: 1000000
model_config:
max_retries: 3
claude_code:
backend: claude_code
model: ${CLAUDE_CODE_MODEL_NAME:-glm-5.2}
api_key: ${CLAUDE_CODE_API_KEY:-}
base_url: ${CLAUDE_CODE_BASE_URL:-https://dashscope.aliyuncs.com/apps/anthropic}
permission_mode: bypassPermissions
lme_final_answer_review:
backend: claude_code
model: ${CLAUDE_CODE_MODEL_NAME:-claude-opus-4-8}
api_key: ${CLAUDE_CODE_API_KEY:-}
base_url: ${CLAUDE_CODE_BASE_URL:-}
cwd: session
permission_mode: bypassPermissions
lme_review:
backend: agentscope
as_llm: plus
permission_mode: bypass
builtin_tools: false
react_config:
max_iters: 10
context_config:
trigger_ratio: 0.89
reserve_ratio: 0.1
tool_result_limit: 1000000
model_config:
max_retries: 3
lme_judge:
backend: agentscope
as_llm: default
permission_mode: bypass
builtin_tools: false
job_tools:
- python_execute
sequential_tool_calls: true
react_config:
max_iters: 50
context_config:
trigger_ratio: 0.89
reserve_ratio: 0.1
tool_result_limit: 1000000
model_config:
max_retries: 3
file_graph:
default:
backend: local
file_chunker:
markdown:
backend: markdown
supported_extensions: [ "md" ]
embed_toc: true
max_ast_sections: 100
default:
backend: default
supported_extensions: [ "json", "jsonl" ]
chunk_byte_size: 100000
keyword_index:
default:
backend: bm25
tokenizer: default
file_store:
default:
backend: local
store_name: local
embedding_store: default
# embedding_store: ""
keyword_index: default
file_graph: default

583
reme/config/lme.yaml Normal file
View file

@ -0,0 +1,583 @@
# LongMemEval benchmark config — based on longmemeval.yaml (split)
# All background/cron jobs are converted to base (manually callable).
# Use with: resolve_app_config(config="lme.yaml", ...)
service:
backend: http
jobs:
# ── LongMemEval agentic answer (ReAct agent + search tool) ──
agentic_answer:
backend: base
description: "LongMemEval agentic answer job (ReAct agent with search tool)"
watch_dirs: []
watch_suffixes: []
parameters:
type: object
properties:
query:
type: string
description: "The query to ask"
query_time:
type: string
description: "ISO timestamp representing the query time"
default: ""
required:
- query
steps:
- backend: lme_agentic_answer_step
agent_wrapper: bench
# ── LLM-as-Judge for evaluating answers ──
answer_judge:
backend: base
description: "LLM-as-Judge: evaluate agent answer against golden answer"
watch_dirs: []
watch_suffixes: []
parameters:
type: object
properties:
query:
type: string
description: "The question being asked"
agent_answer:
type: string
description: "The model's answer to evaluate"
golden_answer:
type: string
description: "The correct/golden answer"
question_type:
type: string
description: "The question type for prompt selection"
default: ""
required:
- query
- agent_answer
- golden_answer
steps:
- backend: lme_answer_judge_step
agent_wrapper: judge
# ── Manual index update (replaces index_update_loop background) ──
index_update:
backend: base
description: "Manually trigger incremental index update for watched dirs."
watch_dirs: [daily_dir, digest_dir, dialog_dir]
watch_suffixes: [md, jsonl]
parameters:
type: object
properties: {}
steps:
- backend: init_changes_step
monitor_type: file_store
monitor_name: default
dispatch_steps: [update_index_step]
# ── Manual digest catalog update (replaces digest_watch_loop background) ──
digest_update:
backend: base
description: "Manually trigger digest catalog update."
watch_dirs: [daily_dir, digest_dir]
watch_suffixes: [md]
parameters:
type: object
properties: {}
steps:
- backend: init_changes_step
monitor_type: file_catalog
monitor_name: digest
dispatch_steps:
- backend: update_catalog_step
file_catalog: digest
- backend: log_changes_step
# ── Auto dream (same as default.yaml auto_dream, base mode) ──
# auto_dream:
# backend: base
# description: "Auto-dream: scan today's day-index and daily notes, globally extract merged units/topics, integrate digest units, write interests.yaml, and persist the dream catalog."
# parameters:
# type: object
# properties:
# date:
# type: string
# description: "YYYY-MM-DD to scan; defaults to today in the dreamer's timezone"
# default: ""
# hint:
# type: string
# description: "caller guidance passed through to dream extract/integrate"
# default: ""
# scan_days:
# type: integer
# description: "number of recent daily directories to scan, ending at date"
# default: 2
# max_units:
# type: integer
# description: "maximum number of extracted memory units"
# default: 5
# topic_count:
# type: integer
# description: "maximum number of final daily interest topics"
# default: 3
# topic_diversity_days:
# type: integer
# description: "number of previous interests.yaml days to avoid repeating"
# default: 7
# steps:
# - backend: dream_extract_step
# file_catalog: dream
# topic_session_id: interests
# scan_days: 2
# max_units: 5
# - backend: dream_integrate_step
# - backend: dream_topics_step
# topic_count: 3
# topic_diversity_days: 7
# - backend: dream_finish_step
# file_catalog: dream
# ── Auto memory (same as default.yaml) ──
auto_memory:
backend: base
description: "Auto-memory: record conversation facts into a daily note"
parameters:
type: object
properties:
messages:
type: array
description: "messages"
items:
type: object
session_id:
type: string
description: "source conversation session identifier"
default: ""
memory_hint:
type: string
description: "optional hint"
date:
type: string
description: "YYYY-MM-DD daily note date; empty = infer from message timestamps or today"
default: ""
required:
- messages
steps:
- backend: lme_auto_memory_step
# ── Reindex (full rebuild) ──
reindex:
backend: base
description: "wipe the file store and rebuild it from the existing files"
watch_dirs: [daily_dir, digest_dir, resource_dir]
watch_suffixes: [md, jsonl]
parameters:
type: object
properties: {}
steps:
- backend: clear_store_step
- backend: init_changes_step
monitor_type: file_store
monitor_name: default
dispatch_steps: [update_index_step]
# ── Search ──
# start_date:
# type: string
# description: "optional inclusive start date filter (YYYY-MM-DD); results earlier than this date are excluded"
# end_date:
# type: string
# description: "optional inclusive end date filter (YYYY-MM-DD); results later than this date are excluded"
search:
backend: base
description: "Hybrid workspace search (vector + BM25, RRF-fused) with deduplication."
parameters:
type: object
properties:
query:
type: string
description: "search query"
start_date:
type: string
description: "optional inclusive start date filter (YYYY-MM-DD); results earlier than this date are excluded"
end_date:
type: string
description: "optional inclusive end date filter (YYYY-MM-DD); results later than this date are excluded"
# vector_weight:
# type: number
# description: >-
# Optional weight balancing vector similarity vs BM25 keyword matching in the
# RRF fusion. Recommended value is 0.7, which provides a good balance between
# semantic (vector) similarity and lexical (BM25) matching. Values close to 0
# emphasize BM25 keyword matching, values close to 1 emphasize vector semantic
# similarity.
required:
- query
steps:
- backend: search_step
vector_weight: 0.7
candidate_multiplier: 5.0
expand_links: true
max_links_per_direction: 10
add_draft:
backend: base
description: "Append text to the current draft list."
parameters:
type: object
properties:
text:
type: string
description: "draft text to append"
required:
- text
steps:
- backend: add_draft_step
read_all_draft:
backend: base
description: "Read all draft text previously appended in the current tool context."
parameters:
type: object
properties: { }
steps:
- backend: read_all_draft_step
python_execute:
backend: base
description: "Execute Python code and return printed stdout."
parameters:
type: object
properties:
code:
type: string
description: "Python code to execute. Print the final result to stdout."
timeout:
type: number
description: "Execution timeout in seconds; defaults to 60."
required:
- code
steps:
- backend: python_execute_step
# ── File I/O jobs (needed by auto_memory agent tools) ──
daily_list:
backend: base
description: "List notes under a single day."
parameters:
type: object
properties:
date:
type: string
description: "YYYY-MM-DD; empty = today"
default: ""
steps:
- backend: daily_list_step
daily_reindex:
backend: base
description: "Rebuild the day-index page daily/<date>.md."
parameters:
type: object
properties:
date:
type: string
description: "YYYY-MM-DD; empty = today"
default: ""
steps:
- backend: daily_reindex_step
frontmatter_update:
backend: base
description: "Merge key-values into a file's frontmatter."
parameters:
type: object
properties:
path:
type: string
description: "workspace-relative path"
metadata:
type: object
description: "key-values to merge"
required:
- path
- metadata
steps:
- backend: frontmatter_update_step
move:
backend: base
description: "Move / rename a workspace file."
parameters:
type: object
properties:
src_path:
type: string
description: "workspace-relative source"
dst_path:
type: string
description: "workspace-relative destination"
overwrite:
type: boolean
default: false
retarget:
type: boolean
default: true
required:
- src_path
- dst_path
steps:
- backend: move_step
read:
backend: base
description: "Read a markdown file under the workspace."
parameters:
type: object
properties:
path:
type: string
description: "workspace-relative path; markdown only"
start_line:
type: integer
end_line:
type: integer
required:
- path
steps:
- backend: read_step
with_neighbors: false
max_neighbors_per_direction: 10
write:
backend: base
description: "Write a markdown file."
parameters:
type: object
properties:
path:
type: string
name:
type: string
description:
type: string
content:
type: string
metadata:
type: object
required:
- path
- name
- description
- content
steps:
- backend: write_step
daily_write:
backend: base
description: "Write a daily markdown note."
parameters:
type: object
properties:
name:
type: string
description:
type: string
session_id:
type: string
content:
type: string
date:
type: string
default: ""
metadata:
type: object
required:
- name
- description
- session_id
- content
steps:
- backend: daily_write_step
edit:
backend: base
description: "Find-and-replace in a markdown file."
parameters:
type: object
properties:
path:
type: string
old:
type: string
new:
type: string
default: ""
required:
- path
- old
- new
steps:
- backend: edit_step
frontmatter_read:
backend: base
description: "Read a file's frontmatter as a dict."
parameters:
type: object
properties:
path:
type: string
required:
- path
steps:
- backend: frontmatter_read_step
node_search:
backend: base
description: "Digest node recall."
parameters:
type: object
properties:
query:
type: string
limit:
type: integer
default: 20
required:
- query
steps:
- backend: node_search_step
vector_weight: 0.7
candidate_multiplier: 5.0
components:
tokenizer:
default:
backend: regex
as_embedding:
default:
backend: ${EMBEDDING_BACKEND:-openai}
model: ${EMBEDDING_MODEL_NAME:-text-embedding-v4}
credential:
api_key: ${EMBEDDING_API_KEY:-}
base_url: ${EMBEDDING_BASE_URL:-https://dashscope.aliyuncs.com/compatible-mode/v1}
dimensions: 1024
embedding_store:
default:
backend: local
as_embedding: default
as_llm:
default:
backend: ${LLM_BACKEND:-openai}
model: ${LLM_MODEL_NAME:-qwen3.6-flash}
stream: true
context_size: 200000
max_retries: 3
retry_delay: 5.0
credential:
api_key: ${LLM_API_KEY:-}
base_url: ${LLM_BASE_URL:-}
parameters:
max_tokens: 65536
thinking_enable: false
judge:
backend: ${LLM_BACKEND:-openai}
model: ${JUDGE_MODEL_NAME:-qwen3.7-max}
stream: false
context_size: 200000
max_retries: 5
credential:
api_key: ${LLM_API_KEY:-}
base_url: ${LLM_BASE_URL:-}
parameters:
max_tokens: 65536
thinking_enable: false
bench:
backend: ${LLM_BACKEND:-openai}
model: ${BENCH_MODEL_NAME:-qwen3.7-max}
stream: true
context_size: 400000
max_retries: 5
retry_delay: 5.0
credential:
api_key: ${LLM_API_KEY:-}
base_url: ${LLM_BASE_URL:-}
parameters:
max_tokens: 65536
thinking_enable: true
agent_wrapper:
default:
backend: agentscope
as_llm: default
permission_mode: bypass
react_config:
max_iters: 30
context_config:
trigger_ratio: 0.8
reserve_ratio: 0.1
tool_result_limit: 50000
model_config:
max_retries: 1
judge:
backend: agentscope
as_llm: judge
permission_mode: bypass
react_config:
max_iters: 1
context_config:
trigger_ratio: 0.8
reserve_ratio: 0.1
tool_result_limit: 50000
model_config:
max_retries: 1
bench:
backend: agentscope
as_llm: bench
permission_mode: bypass
react_config:
max_iters: 30
context_config:
trigger_ratio: 0.8
reserve_ratio: 0.1
tool_result_limit: 50000
model_config:
max_retries: 1
file_graph:
default:
backend: local
file_catalog:
default:
backend: local
resource:
backend: local
digest:
backend: local
dream:
backend: local
file_chunker:
markdown:
backend: markdown
supported_extensions: ["md"]
default:
backend: default
supported_extensions: ["jsonl"]
keyword_index:
default:
backend: bm25
tokenizer: default
file_store:
default:
backend: local
store_name: local
embedding_store: default
keyword_index: default
file_graph: default

View file

@ -39,6 +39,7 @@ class ApplicationConfig(BaseModel):
resource_dir: str = Field(default="resource", description="Subdirectory for external assets")
daily_dir: str = Field(default="daily", description="Subdirectory for daily memory")
digest_dir: str = Field(default="digest", description="Subdirectory for digest memory")
dialog_dir: str = Field(default="session/dialog", description="Subdirectory for dialog session transcripts")
enable_logo: bool = Field(default=True, description="Show ASCII logo on startup")
timezone: str | None = Field(default="Asia/Shanghai", description="IANA timezone; None uses local time")
language: str = Field(default="", description="Default language for LLM interactions")

View file

@ -1,12 +1,17 @@
"""Benchmark steps."""
from . import lme
from .lme import ContextAnswerStep, GoldenCheckStep, LmeLlmJudgeStep, SessionReviewStep
from . import base, lme, beam
from .base import BaseAgenticAnswerStep
from .lme import LmeAgenticAnswerStep, LmeAnswerJudgeStep
from .beam import BeamAgenticAnswerStep, BeamRubricJudgeStep
__all__ = [
"ContextAnswerStep",
"GoldenCheckStep",
"LmeLlmJudgeStep",
"SessionReviewStep",
"BaseAgenticAnswerStep",
"LmeAgenticAnswerStep",
"LmeAnswerJudgeStep",
"BeamAgenticAnswerStep",
"BeamRubricJudgeStep",
"base",
"lme",
"beam",
]

View file

@ -0,0 +1,7 @@
"""Shared base classes for benchmark steps."""
from .agentic_answer import BaseAgenticAnswerStep
__all__ = [
"BaseAgenticAnswerStep",
]

View file

@ -0,0 +1,102 @@
"""Shared base class for benchmark agentic-answer steps."""
import os
import threading
from ...base_step import BaseStep
from ....enumeration import ChunkEnum
# ---------------------------------------------------------------------------
# Process-safe & thread-safe counter for unique tool_context_id.
# PID guarantees cross-process uniqueness (multiprocessing Pool);
# threading.Lock + monotonic counter guarantees thread safety within a process.
# ---------------------------------------------------------------------------
_TOOL_CTX_LOCK = threading.Lock()
_TOOL_CTX_SEQ = 0
def _next_tool_context_id(prefix: str) -> str:
global _TOOL_CTX_SEQ
with _TOOL_CTX_LOCK:
_TOOL_CTX_SEQ += 1
seq = _TOOL_CTX_SEQ
return f"{prefix}_{os.getpid()}_{seq}"
class BaseAgenticAnswerStep(BaseStep):
"""Base ReAct-agent answer step shared by BEAM and LongMemEval benchmarks.
Subclasses only need to set:
TOOL_CONTEXT_PREFIX (str): prefix used to build the unique tool_context_id.
And apply their own ``@R.register(...)`` decorator and docstring.
Inputs (from RuntimeContext):
query (str, required): The question to answer.
query_time (str, optional): ISO timestamp representing the query time,
used to ground the agent's temporal context.
Output (written to context.response.answer):
The agent's final answer text.
"""
MAX_ITERATION = 10
TOOL_CONTEXT_PREFIX: str = "agentic_answer"
async def execute(self):
assert self.context is not None
query: str = self.context.get("query", "")
query_time: str | None = self.context.get("query_time")
if not query:
self.context.response.success = False
self.context.response.answer = "Skipped: empty query"
return self.context.response
# Build system prompt with optional temporal context
sys_prompt = self.get_prompt("system_prompt")
if query_time:
sys_prompt += "\n" + self.prompt_format("temporal_hint", query_time=query_time)
wrapper_kwargs = {
"system_prompt": sys_prompt,
"job_tools": ["search", "add_draft", "read_all_draft"],
"react_config": {"max_iters": self.MAX_ITERATION},
"tool_context_id": _next_tool_context_id(self.TOOL_CONTEXT_PREFIX),
}
if self.context.stream:
text = await self._stream_reply(query, **wrapper_kwargs)
else:
result = await self.agent_wrapper.reply(query, **wrapper_kwargs)
text = (result.get("result") or "").strip()
self.logger.debug(f"[{self.name}] response: {text!r}")
self.context.response.success = True
self.context.response.answer = text
self.context.response.metadata.update(
{
"query": query,
"query_time": query_time,
"sys_prompt": sys_prompt,
"response": text,
},
)
return self.context.response
async def _stream_reply(self, query: str, **wrapper_kwargs) -> str:
"""Stream unified chunks to the context stream queue."""
assert self.context is not None
text_parts: list[str] = []
async for chunk in self.agent_wrapper.reply_stream(query, **wrapper_kwargs):
await self.context.add_stream_string(chunk.chunk, chunk.chunk_type)
if chunk.chunk_type == ChunkEnum.CONTENT and isinstance(chunk.chunk, str):
text_parts.append(chunk.chunk)
if chunk.session_id:
self.context.response.metadata["session_id"] = chunk.session_id
return "".join(text_parts).strip()

View file

@ -0,0 +1,11 @@
"""BEAM benchmark steps."""
from .agentic_answer import BeamAgenticAnswerStep
from .llm_judge import BeamRubricJudgeStep
from .auto_memory import BeamAutoMemoryStep
__all__ = [
"BeamAgenticAnswerStep",
"BeamRubricJudgeStep",
"BeamAutoMemoryStep",
]

View file

@ -0,0 +1,16 @@
"""BEAM agentic answer step – ReAct agent that answers questions using the search tool."""
from ....components import R
from ..base import BaseAgenticAnswerStep
@R.register("beam_agentic_answer_step")
class BeamAgenticAnswerStep(BaseAgenticAnswerStep):
"""Answer a BEAM probing question via ReAct agent with access to the search tool.
The agent uses the ``agent_wrapper`` component in ReAct mode, calling the
``search`` job tool to retrieve relevant memory chunks before generating
a final answer.
"""
TOOL_CONTEXT_PREFIX = "beam_agentic_answer"

View file

@ -0,0 +1,15 @@
system_prompt: |
You are a memory retrieval assistant. You MUST use the search tool to find information before answering.
- Your total time of tool calls should be at most 9 times
## Search Strategy
- You can call 'search' tool to search multiple times (at least once) with different queries to gather comprehensive information.
## Draft Tool
- Use 'add_draft' to save key findings during search, and 'read_all_draft' to review all saved notes before answering.
## Answer Rules
- Answer based ONLY on retrieved context.
- Output ONLY the direct factual answer — no reasoning, no search process, no elaboration.
- If information is not founded or not sufficient after multiple searches, reply: 'Information not found.'
temporal_hint: |
Current time context: {query_time}

View file

@ -0,0 +1,132 @@
"""auto_memory (beam) — AutoMemoryStep with timestamp interpolation and daily_write date default."""
from datetime import datetime, timedelta
from agentscope.message import Msg
from ...evolve.auto_memory import AutoMemoryStep, _normalize_msg_timestamp
from ....components import R
def _parse_iso_seconds(value: str) -> datetime | None:
"""Parse an ISO-8601 timestamp that is precise to at least seconds.
Accepts formats like:
2026-07-01T14:30:00
2026-07-01T14:30:00Z
2026-07-01T14:30:00+08:00
2026-07-01T14:30:00.123456
Rejects date-only (``2026-07-01``) or minute-only (``2026-07-01T14:30``).
Returns ``None`` when the value does not satisfy the requirements.
"""
text = str(value).strip()
# Minimum valid: YYYY-MM-DDTHH:MM:SS = 19 chars
if len(text) < 19:
return None
# Must contain 'T' separator and at least HH:MM:SS after it
if "T" not in text:
return None
time_part = text.split("T", 1)[1]
# time_part must start with HH:MM:SS (8 chars minimum)
if len(time_part) < 8 or time_part[2] != ":" or time_part[5] != ":":
return None
try:
return datetime.fromisoformat(text)
except (ValueError, TypeError):
return None
def _interpolate_timestamps(items: list[dict]) -> list[dict]:
"""Fill missing ``created_at`` fields via linear interpolation.
Rules (backward-compatible — returns *items* unchanged when no message
carries a ``created_at`` value):
1. If **no** message has ``created_at`` → return as-is (system time used
later by AgentScope's ``Msg`` constructor).
2. Messages **before** the first timestamped message → inherit the first
timestamp.
3. Messages **after** the last timestamped message → inherit the last
timestamp.
4. Messages **between** two timestamped anchors → linearly interpolated.
"""
# Pass 1: normalize aliases and collect anchors
normalized: list[dict] = []
anchors: list[tuple[int, datetime]] = [] # (index, parsed_dt)
for i, raw_item in enumerate(items):
if not isinstance(raw_item, dict):
normalized.append(raw_item)
continue
item = _normalize_msg_timestamp(raw_item)
normalized.append(item)
ca = item.get("created_at")
if ca:
dt = _parse_iso_seconds(str(ca))
if dt is not None:
anchors.append((i, dt))
# No anchors → fully backward-compatible, let Msg use system time
if not anchors:
return normalized
# Pass 2: interpolate
result: list[dict] = []
for i, item in enumerate(normalized):
if not isinstance(item, dict):
result.append(item)
continue
# Already has a valid parsed anchor — keep it
if any(idx == i for idx, _ in anchors):
result.append(item)
continue
# Find the nearest preceding and following anchors
prev_anchor: tuple[int, datetime] | None = None
next_anchor: tuple[int, datetime] | None = None
for idx, dt in anchors:
if idx < i:
prev_anchor = (idx, dt)
for idx, dt in anchors:
if idx > i:
next_anchor = (idx, dt)
break
# Determine interpolated time
if prev_anchor is None:
# Before the first anchor
interpolated_dt = anchors[0][1]
elif next_anchor is None:
# After the last anchor
interpolated_dt = anchors[-1][1]
else:
# Between two anchors — linear
prev_idx, prev_dt = prev_anchor
next_idx, next_dt = next_anchor
span = next_idx - prev_idx
ratio = (i - prev_idx) / span
delta_seconds = (next_dt - prev_dt).total_seconds()
interpolated_dt = prev_dt + timedelta(seconds=delta_seconds * ratio)
item = {**item, "created_at": interpolated_dt.isoformat()}
result.append(item)
return result
@R.register("beam_auto_memory_step")
class BeamAutoMemoryStep(AutoMemoryStep):
"""AutoMemoryStep variant that interpolates timestamps and pins daily_write to the resolved day."""
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
# time ordering (see _interpolate_timestamps docstring for rules).
interpolated = _interpolate_timestamps(
[item if not isinstance(item, dict) else dict(item) for item in raw_messages],
)
return [self._to_msg(item) for item in interpolated]
def _reply_extra_kwargs(self, day: str) -> dict:
return {"tool_defaults": {"daily_write": {"date": day}}}

View file

@ -0,0 +1,334 @@
system_prompt: |
You are an automatic memory extraction system. Your task is to extract ALL valuable information from conversations and record them into a structured daily memory note. Ensure **complete coverage** — every piece of information worth preserving must be captured.
## Extraction Principles
1. **Complete Coverage**: Generate enough content to ensure ALL information in the dialogues is captured. Missing information is worse than redundancy.
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.
## What to Extract
- **Persons**: All person names mentioned (full names when available)
- **Entities**: Companies, products, organizations, projects, repositories
- **Decisions & Events**: What happened, what was decided, and why
- **Facts & Preferences**: Persistent facts about the user — identity, habits, preferences, constraints
- **Procedures & Solutions**: Actionable steps, commands, configurations that can be directly reused
- **Current State**: Progress, blockers, next steps, open questions
- **Temporal Information**: Use absolute dates in ISO 8601 format (YYYY-MM-DD) when explicit time is mentioned
- **Locations**: Specific location names when mentioned
- **Topic Keywords**: Core keywords that identify the subject matter
## Body Format
Use structured sections with headings. Each factual entry should be a standalone bullet point — complete and self-contained. Group entries by topic. Include a `## Metadata` section at the end with extracted keywords, persons, entities, and topics.
## Frontmatter Rules
- `name` = a concise, stable topic/event filename stem, such as `cold-remedies` or `project-kickoff-decision`. Do not include today's date or the daily directory date; the outer daily path already records the date. For existing notes, update it when a better filename is clearly warranted.
- `description` = a thorough, lossless summary; vague descriptions like "notes" / "misc" are unacceptable. The description alone should convey all key information.
- **Never set `status`** — it is a field reserved for downstream processing.
## Tool Call Format — CRITICAL
You have access to tools (e.g. `daily_write`, `read`, `edit`, `write`, `frontmatter_update`). You MUST invoke them as **structured tool calls** (function calls), NOT as plain text output.
- **NEVER** write tool invocations as text strings such as `daily_write name=...`. Text output does NOT execute tools — no memory will be saved.
- **ALWAYS** use the tool call mechanism provided by the runtime. The system handles parameter passing automatically.
- If you write a tool name and its parameters as text, NO tool will be executed and NO memory will be saved.
system_prompt_zh: |
你是自动记忆提取系统。你的任务是从对话中提取所有有价值的信息,并记录到结构化的日记记忆中。确保**完整覆盖**——每一条值得保留的信息都必须被捕获。
## 提取原则
1. **完整覆盖**:生成足够的内容以确保对话中的所有信息都被捕获。遗漏信息比冗余更糟。
2. **强制消歧**:绝对禁止使用代词(他、她、它、他们、这个、那个)或相对时间(昨天、今天、上周、明天、最近)。必须使用全名、明确日期和具体引用。
3. **无损信息**:每条记录的事实必须是完整的、独立的、可理解的陈述。没有上下文的读者必须能单独理解每条记录。
4. **原文保留**:逐字引用原始措辞、数字、URL、命令和代码片段。
## 提取什么
- **人物**:提及的所有人名(尽量使用全名)
- **实体**:公司、产品、组织、项目、仓库
- **决策与事件**:发生了什么、决定了什么、为什么
- **事实与偏好**:关于用户的持久事实——身份、习惯、偏好、约束
- **流程与方案**:可直接复用的操作步骤、命令、配置
- **当前状态**:进度、卡点、下一步、未决问题
- **时间信息**:当对话中明确提及时间时,使用 ISO 8601 绝对日期格式(YYYY-MM-DD)
- **地点**:提及的具体地点名称
- **主题关键词**:标识主题的核心关键词
## 正文格式
使用带标题的结构化分节。每条事实记录应是独立的列表项——完整且自包含。按主题分组。在末尾添加 `## Metadata` 部分,列出提取的关键词、人物、实体和主题。
## Frontmatter 规则
- `name` = 简洁、稳定的主题/事件文件名 stem,例如 `cold-remedies` 或 `project-kickoff-decision`。不要包含今天日期或日记目录日期;外层日记路径已经记录日期。对已有笔记,如果明显有更好的文件名,就更新它。
- `description` = 详尽的无损总结;模糊的描述如 "notes" / "misc" 不可接受。仅凭 description 就应能传达全部核心信息。
- **永远不要设置 `status`**——它是下游处理保留的字段。
## 工具调用格式 — 关键
你可以使用工具(如 `daily_write`、`read`、`edit`、`write`、`frontmatter_update`)。你**必须**以**结构化工具调用**(函数调用)的方式调用它们,而不是以纯文本输出。
- **绝不**将工具调用作为文本字符串输出,如 `daily_write name=...`。文本输出**不会**执行工具——不会保存任何记忆。
- **始终**使用运行时提供的工具调用机制。系统会自动处理参数传递。
- 如果你将工具名和参数作为文本输出,**不会有任何工具被执行**,也**不会**保存任何记忆。
user_message_create: |
Today: {today}
Extra hint: {note}
Session ID: {session_id}
# Recent Conversation
{history}
# Your Task
Extract all valuable information from the conversation above and record them into a new daily memory note.
## Step 1 — Skip Check
Did the conversation produce substantive information worth long-term memory? Pure greetings or small talk → reply with a brief skip message and stop (do not call any tools).
When truly ambiguous, default to writing — losing a memory is worse than writing one extra note.
## Step 2 — Extract & Write
Analyze the conversation and extract:
- **keywords**: Core keywords (names, places, entities, topic words)
- **persons**: All person names mentioned
- **entities**: Companies, products, organizations, projects
- **topic**: The main topic(s) of the conversation
- **timestamp**: Absolute time in ISO 8601 format (if explicit time mentioned)
- **location**: Specific location name (if mentioned)
Then create the note by **calling the `daily_write` tool** with these parameters:
- `name`: <a concise, stable topic/event filename stem in kebab-case>
- `description`: <a thorough, lossless summary>
- `session_id`: {session_id}
- `date`: {today}
- `content`: <the full body text>
⚠️ 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:
- Each factual entry must be a complete, independent, self-contained statement (lossless restatement).
- PROHIBIT pronouns (he/she/it/they/this/that) — use full names and explicit references.
- PROHIBIT relative time (yesterday/today/last week) — use absolute dates (YYYY-MM-DD).
- Group entries by topic with headings.
- End with a `## Metadata` section containing: keywords, persons, entities, topic.
Filename and description rules:
- `name`: concise, stable topic/event filename stem in kebab-case. No date, no slash/backslash, no `< > : " | ? *`.
- `description`: a thorough, lossless summary — specific enough that the description alone conveys all key information.
## Step 3 — Summary
State in one sentence what you did (which file was created). This is your final text output.
## Boundaries
- Create at most 3 notes for this session. If the conversation covers multiple distinct topics, split them into separate notes (one topic per note). If only one topic is discussed, create just one note.
user_message_create_zh: |
今天:{today}
额外提示:{note}
Session ID:{session_id}
# 最近的对话
{history}
# 你的任务
从上述对话中提取所有有价值的信息,并记录到一条新的日记记忆中。
## 步骤 1 — 跳过检查
对话是否产生了值得长期记忆的实质性信息?纯粹的寒暄或闲聊 → 回复一条简短的跳过消息并停止(不调用任何工具)。
当真正模棱两可时,默认写入——丢失记忆比多写一条笔记更糟。
## 步骤 2 — 提取并写入
分析对话并提取:
- **keywords**:核心关键词(人名、地点、实体、主题词)
- **persons**:提及的所有人名
- **entities**:公司、产品、组织、项目
- **topic**:对话的主要主题
- **timestamp**:ISO 8601 格式的绝对时间(如果对话中明确提及时间)
- **location**:具体地点名称(如果提及)
然后**调用 `daily_write` 工具**,使用以下参数一次性创建笔记:
- `name`:<简洁、稳定的主题/事件文件名 stem,采用 kebab-case>
- `description`:<详尽的无损总结>
- `session_id`:{session_id}
- `date`:{today}
- `content`:<完整正文>
⚠️ 关键:你**必须**以**工具调用**(函数调用)方式调用 `daily_write`。不要将 `daily_write name=...` 作为纯文本输出——那样**不会**执行工具,也**不会**保存任何记忆。
正文要求:
- 每条事实记录必须是完整的、独立的、自包含的陈述(无损复述)。
- 禁止使用代词(他/她/它/他们/这个/那个)——必须使用全名和明确引用。
- 禁止使用相对时间(昨天/今天/上周)——必须使用绝对日期(YYYY-MM-DD)。
- 按主题分组并使用标题。
- 末尾添加 `## Metadata` 部分,包含:keywords、persons、entities、topic。
文件名和描述规则:
- `name`:简洁、稳定的主题/事件文件名 stem,采用 kebab-case。不含日期、不含 slash/反斜杠、不含 `< > : " | ? *`。
- `description`:详尽的无损总结——具体到仅凭 description 就能传达全部核心信息。
## 步骤 3 — 总结
用一句话说明你做了什么(创建了哪个文件)。这是你最后一次文本输出。
## 边界
- 当前 session 最多创建 3 条笔记。如果对话涵盖多个不同主题,将其拆分为独立笔记(每个主题一条)。如果只讨论了一个主题,则只创建一条笔记。
user_message_update: |
Today: {today}
Extra hint: {note}
Target path: {note_path}
# Recent Conversation
{history}
# Your Task
Extract all valuable information from the conversation above and merge them into the existing daily note at the target path.
## Step 1 — Skip Check
Did the conversation produce substantive information worth long-term memory? Pure greetings or small talk → reply with a brief skip message and stop (do not call any tools).
When truly ambiguous, default to writing — losing a memory is worse than writing one extra note.
## Step 2 — Read Existing Content
**Call the `read` tool** with parameter `path` set to `{note_path}` to inspect the current note content. You MUST invoke it as a tool call, not as text.
- If the body is empty (only frontmatter, no actual content) → treat as new, jump to **Step 3b**.
- If there is body content → go to **Step 3a** to merge.
## Step 3a — Merge Update
The note already has content. Extract new information from the conversation and merge into it.
Extraction rules (apply to ALL new entries):
- Each new fact must be a complete, independent, self-contained statement (lossless restatement).
- PROHIBIT pronouns (he/she/it/they/this/that) — use full names and explicit references.
- PROHIBIT relative time (yesterday/today/last week) — use absolute dates (YYYY-MM-DD).
Merge rules:
- **Timeline / history entries**: append only, never delete existing entries.
- **Current-state entries** (progress, blockers, next steps, open questions): rewrite the entire section to reflect the latest snapshot.
- **Everything else**: merge and deduplicate — keep all old facts, add new facts, remove exact duplicates.
- **Metadata section**: merge keywords, persons, entities, topics — union of old and new values.
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.
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`.
3. If `edit` fails repeatedly (e.g., cannot find the original text due to formatting mismatch), fall back to **calling the `write` tool** with parameters: `path`="{note_path}", `name`=<name>, `description`=<description>, `content`=<full body> for a complete rewrite.
## Step 3b — Full Write (Empty File Fallback)
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:
- Each factual entry must be a complete, independent, self-contained statement.
- PROHIBIT pronouns — use full names and explicit references.
- PROHIBIT relative time — use absolute dates (YYYY-MM-DD).
- Group entries by topic with headings.
- End with a `## Metadata` section containing: keywords, persons, entities, topic.
Filename and description rules:
- `name`: concise, stable topic/event filename stem in kebab-case. No date.
- `description`: a thorough, lossless summary — the description alone should convey all key information.
## Step 4 — Summary
State in one sentence what you did (what content was updated). This is your final text output.
## Boundaries
- Only operate on one target path: `{note_path}`. Do not touch other notes.
- `write` unconditionally overwrites body and frontmatter — use with caution.
- Filename changes are expressed by updating frontmatter `name`.
user_message_update_zh: |
今天:{today}
额外提示:{note}
目标路径:{note_path}
# 最近的对话
{history}
# 你的任务
从上述对话中提取所有有价值的信息,并合并到目标路径的已有日记中。
## 步骤 1 — 跳过检查
对话是否产生了值得长期记忆的实质性信息?纯粹的寒暄或闲聊 → 回复一条简短的跳过消息并停止(不调用任何工具)。
当真正模棱两可时,默认写入——丢失记忆比多写一条笔记更糟。
## 步骤 2 — 读取现有内容
**调用 `read` 工具**,参数 `path` 设为 `{note_path}`,查看当前笔记内容。必须以工具调用方式调用,不要作为文本输出。
- 如果正文为空(只有 frontmatter 无实际内容)→ 按新建处理,跳到 **步骤 3b**。
- 如果有正文内容 → 转到 **步骤 3a** 进行合并。
## 步骤 3a — 合并更新
笔记已有内容。从对话中提取新信息并合并进去。
提取规则(适用于所有新条目):
- 每条新事实必须是完整的、独立的、自包含的陈述(无损复述)。
- 禁止使用代词(他/她/它/他们/这个/那个)——必须使用全名和明确引用。
- 禁止使用相对时间(昨天/今天/上周)——必须使用绝对日期(YYYY-MM-DD)。
合并规则:
- **时间线 / 历史条目**:仅追加,永远不删除已有条目。
- **当下状态类条目**(进度、卡点、下一步、未决问题):整段重写,反映最新快照。
- **其余内容**:合并去重——保留全部旧事实,添加新事实,去除完全重复项。
- **Metadata 部分**:合并 keywords、persons、entities、topics——取旧值和新值的并集。
执行:
1. **调用 `edit` 工具**更新需要修改的部分,参数为:`path`="{note_path}"、`old`=<原文片段>、`new`=<替换片段>。可以多次调用 `edit`。每次调用必须是工具调用,不是文本。
2. 正文变更后,**调用 `frontmatter_update` 工具**刷新 frontmatter,参数为:`path`="{note_path}"、`metadata`={{"name": "<更新后的文件名 stem>", "description": "<更新后的总结>"}}。
- 只有当前 `name` 已经是最合适的简洁主题/事件文件名 stem 时才保留。系统会在你最终回复后负责重命名文件。
- 不要在 `name` 中加入今天日期或日记目录日期。
3. 如果 `edit` 多次失败(如因格式不匹配找不到原文),退回**调用 `write` 工具**全量重写,参数为:`path`="{note_path}"、`name`=<name>、`description`=<description>、`content`=<完整正文>。
## 步骤 3b — 全量写入(空文件 fallback)
文件存在但正文为空。提取并**调用 `write` 工具**一次性写入完整内容,参数为:`path`="{note_path}"、`name`=<name>、`description`=<description>、`content`=<正文>。
正文要求:
- 每条事实记录必须是完整的、独立的、自包含的陈述。
- 禁止使用代词——必须使用全名和明确引用。
- 禁止使用相对时间——必须使用绝对日期(YYYY-MM-DD)。
- 按主题分组并使用标题。
- 末尾添加 `## Metadata` 部分,包含:keywords、persons、entities、topic。
文件名和描述规则:
- `name`:简洁、稳定的主题/事件文件名 stem,采用 kebab-case。不含日期。
- `description`:详尽的无损总结——仅凭 description 就应能传达全部核心信息。
## 步骤 4 — 总结
用一句话说明你做了什么(更新了哪些内容)。这是你最后一次文本输出。
## 边界
- 只针对一个目标路径:`{note_path}`。不要碰其他笔记。
- `write` 会无条件覆盖正文和 frontmatter,请谨慎使用。
- 文件名变化通过更新 frontmatter `name` 表达。

View file

@ -0,0 +1,366 @@
"""BEAM rubric-based LLM-as-Judge step.
For each rubric item, calls the judge LLM with the unified judge prompt
and collects scores (0.0 / 0.5 / 1.0). The final ``llm_judge_score`` is
the average across all rubric items.
For ``event_ordering`` questions, additionally computes:
- LLM-based event alignment (matching system events to reference events)
- precision / recall / f1 (set-intersection after alignment)
- Kendall's tau (ordering correlation, pure numpy implementation)
- final_score = tau_norm * f1
A ``semantic`` alignment path is also available which uses ReMe's
configured ``as_embedding`` model (replacing BEAM's sentence_transformers).
This replicates the evaluation logic from
``benchmark/datasets/BEAM/src/evaluation/compute_metrics.py``.
"""
import json
import re
from typing import List, Tuple
import numpy as np
from json_repair import repair_json
from ...base_step import BaseStep, Ref
from ....components import R
from ....components.as_embedding import BaseAsEmbedding
from ....enumeration import ComponentEnum
# ---------------------------------------------------------------------------
# JSON parsing helper (replicates BEAM's parse_json_response)
# ---------------------------------------------------------------------------
def _parse_json_response(response: str) -> dict:
response = response.strip()
if response.startswith("```"):
match = re.search(
r"```(?:json)?\s*(\[.*\]|\{.*\})\s*```",
response,
re.DOTALL,
)
if match:
response = match.group(1).strip()
try:
return json.loads(response)
except json.JSONDecodeError:
pass
match = re.search(r"(\{.*?\}|\[.*?\])", response, re.DOTALL)
if match:
json_part = match.group(1)
try:
return json.loads(json_part)
except Exception as e:
raise ValueError(f"Found possible JSON but failed to parse it: {e}") from e
raise ValueError("No valid JSON found in response.")
# ---------------------------------------------------------------------------
# Event-ordering helpers (replicate BEAM's compute_metrics.py)
# ---------------------------------------------------------------------------
async def _llm_equivalence(agent_wrapper, reference: str, system: str) -> bool:
"""Binary classifier: do the two snippets describe the SAME event/fact?
Replicates BEAM's ``llm_equivalence`` using ``agent_wrapper.reply()``.
"""
system_prompt = (
"You are a binary classifier.\n"
"If the TWO snippets describe the SAME event/fact, reply **YES**\n"
"Otherwise reply **NO**. No extra words.\n"
"DO NOT provide any explanation."
)
user_prompt = f"First snippet: {reference}\n\nSecond snippet: {system}"
result = await agent_wrapper.reply(user_prompt, system_prompt=system_prompt)
raw = (result.get("result") or "").strip().lower()
return "yes" in raw
async def _align_with_llm(
agent_wrapper,
reference: List[str],
system: List[str],
) -> Tuple[List[str], List[str]]:
"""Align system events to reference events via LLM equivalence.
Replicates BEAM's ``align_with_llm``: for each system event, find the
first unmatched reference event that is LLM-equivalent. If found,
replace the system event with the reference text (canonicalisation).
Ensures 1-to-1 mapping.
"""
used = set()
system_out = []
for s in system:
matched_index = None
for index, r in enumerate(reference):
if index in used:
continue
if await _llm_equivalence(agent_wrapper, reference=r, system=s):
matched_index = index
break
if matched_index is not None:
system_out.append(reference[matched_index])
used.add(matched_index)
else:
system_out.append(s)
return reference, system_out
async def _semantic_align(
embedding_fn,
reference: List[str],
system: List[str],
thr: float = 0.65,
) -> Tuple[List[str], List[str]]:
"""Align system events to reference events via embedding cosine similarity.
Replaces BEAM's ``semantic_align`` (which used sentence_transformers)
with ReMe's configured ``as_embedding`` model.
"""
if not reference or not system:
return reference, system
ref_embeddings = np.array(await embedding_fn(reference))
sys_embeddings = np.array(await embedding_fn(system))
# Normalise
ref_norms = ref_embeddings / (np.linalg.norm(ref_embeddings, axis=1, keepdims=True) + 1e-12)
sys_norms = sys_embeddings / (np.linalg.norm(sys_embeddings, axis=1, keepdims=True) + 1e-12)
used_reference = set()
system_canon = []
for i, s_txt in enumerate(system):
sims = sys_norms[i] @ ref_norms.T # cosine similarity
best = int(np.argmax(sims))
if sims[best] >= thr and best not in used_reference:
system_canon.append(reference[best])
used_reference.add(best)
else:
system_canon.append(s_txt)
return reference, system_canon
def _kendall_tau_b(x: list, y: list) -> float:
"""Compute Kendall's tau-b rank correlation using only numpy.
Replicates ``scipy.stats.kendalltau(x, y, variant='b')`` for the
rank-based inputs used in event ordering scoring.
"""
x_arr = np.asarray(x, dtype=float)
y_arr = np.asarray(y, dtype=float)
n = len(x_arr)
if n < 2:
return 0.0
concordant = 0
discordant = 0
x_ties = 0
y_ties = 0
for i in range(n - 1):
for j in range(i + 1, n):
dx = x_arr[j] - x_arr[i]
dy = y_arr[j] - y_arr[i]
if dx == 0 and dy == 0:
x_ties += 1
y_ties += 1
elif dx == 0:
x_ties += 1
elif dy == 0:
y_ties += 1
elif (dx > 0) == (dy > 0):
concordant += 1
else:
discordant += 1
n0 = n * (n - 1) / 2
denom = np.sqrt((n0 - x_ties) * (n0 - y_ties))
if denom == 0:
return 0.0
return (concordant - discordant) / denom
def _event_ordering_score(
reference_canon: List[str],
system_canon: List[str],
) -> dict:
"""Compute precision/recall/f1 + Kendall's tau after alignment.
Replicates BEAM's ``event_ordering_score`` (the scoring part, after
alignment is done).
"""
tp = len(set(reference_canon) & set(system_canon))
fp = len([x for x in system_canon if x not in reference_canon])
fn = len([x for x in reference_canon if x not in system_canon])
precision = tp / (tp + fp) if tp + fp else 0
recall = tp / (tp + fn) if tp + fn else 0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0
union = list(dict.fromkeys(reference_canon + system_canon))
tie_rank = len(union) + 1
def to_rank(seq):
r = {item: i + 1 for i, item in enumerate(seq)}
return [r.get(u, tie_rank) for u in union]
tau_b = _kendall_tau_b(
to_rank(reference_canon),
to_rank(system_canon),
)
tau_b_norm = (tau_b + 1) / 2 if tau_b is not None else 0
final_score = tau_b_norm * f1
return {
"precision": precision,
"recall": recall,
"f1": f1,
"tau_norm": tau_b_norm,
"final_score": final_score,
}
@R.register("beam_rubric_judge_step")
class BeamRubricJudgeStep(BaseStep):
"""Judge an LLM response against a list of rubric criteria.
Inputs (from RuntimeContext):
llm_response (str, required): The model's response to evaluate.
rubric (list[str], required): Rubric criteria to check.
probing_question (str, optional): The original probing question.
question_type (str, optional): Question type (e.g. "event_ordering").
Output (written to context.response):
answer = str(llm_judge_score)
metadata["llm_judge_score"] = float
metadata["llm_judge_responses"] = list[dict]
metadata["event_ordering"] = dict (only for event_ordering type)
"""
as_embedding: BaseAsEmbedding = Ref(
BaseAsEmbedding,
ComponentEnum.AS_EMBEDDING,
optional=True,
)
async def execute(self):
assert self.context is not None
llm_response: str = self.context.get("llm_response", "")
rubric: list[str] = self.context.get("rubric", [])
probing_question: str = self.context.get("probing_question", "")
question_type: str = self.context.get("question_type", "")
if not llm_response:
raise ValueError("beam_rubric_judge_step requires non-empty llm_response")
if not rubric:
raise ValueError("beam_rubric_judge_step requires non-empty rubric")
if self.agent_wrapper is None:
raise RuntimeError("beam_rubric_judge_step requires agent_wrapper")
# ----- Standard rubric-based LLM-as-Judge (all question types) -----
judge_template = self.get_prompt("judge_prompt")
llm_judge_responses: list[dict] = []
total_score = 0.0
for item in rubric:
prompt = judge_template.replace("<rubric_item>", item).replace("<llm_response>", llm_response)
result = await self.agent_wrapper.reply(prompt)
raw = (result.get("result") or "").strip()
try:
parsed = _parse_json_response(raw)
except Exception:
try:
parsed = json.loads(repair_json(raw))
except Exception:
parsed = {"score": 0.0, "reason": f"Failed to parse: {raw[:200]}"}
score = float(parsed.get("score", 0))
# Abstention: binary classification — 1.0 stays 1, <1.0 becomes 0
if question_type == "abstention":
score = 1.0 if score >= 1.0 else 0.0
total_score += score
llm_judge_responses.append(parsed)
llm_judge_score = total_score / len(rubric) if rubric else 0.0
self.logger.info(f"[{self.name}] judge score: {llm_judge_score:.3f}")
self.context.response.success = True
self.context.response.answer = str(llm_judge_score)
self.context.response.metadata.update(
{
"llm_judge_score": llm_judge_score,
"llm_judge_responses": llm_judge_responses,
"rubric": rubric,
"llm_response": llm_response,
"probing_question": probing_question,
"question_type": question_type,
},
)
# ----- event_ordering extra metrics -----
# Replicates BEAM's evaluate_event_ordering: system_list = llm_response.split("\n")
# Note: BEAM calls extract_facts first but immediately overwrites with split("\n").
if question_type == "event_ordering":
eo_metrics = await self._compute_event_ordering(
rubric=rubric,
llm_response=llm_response,
)
self.context.response.metadata["event_ordering"] = eo_metrics
self.logger.info(f"[{self.name}] event_ordering: {eo_metrics}")
return self.context.response
async def _compute_event_ordering(
self,
rubric: list[str],
llm_response: str,
) -> dict:
"""Compute event_ordering extra metrics.
Uses ``align_type="llm"`` to match BEAM's original code.
Also supports ``align_type="semantic"`` via ReMe's embedding model
(replacing sentence_transformers).
"""
# BEAM: system_list = llm_response.split("\n")
system_list = [line for line in llm_response.split("\n") if line.strip()]
# Use LLM alignment (matching BEAM's align_type="llm")
reference_canon, system_canon = await _align_with_llm(
agent_wrapper=self.agent_wrapper,
reference=rubric,
system=system_list,
)
eo_score = _event_ordering_score(reference_canon, system_canon)
# Also compute semantic alignment if embedding is available
if self.as_embedding is not None:
try:
ref_canon_sem, sys_canon_sem = await _semantic_align(
embedding_fn=self.as_embedding,
reference=rubric,
system=system_list,
)
eo_score_sem = _event_ordering_score(ref_canon_sem, sys_canon_sem)
eo_score["semantic_alignment"] = eo_score_sem
except Exception as e:
self.logger.warning(f"[{self.name}] semantic_align failed: {e}")
return eo_score

View file

@ -0,0 +1,70 @@
judge_prompt: |
You are an expert evaluator tasked with judging whether the LLM's response demonstrates compliance with the specified RUBRIC CRITERION.
## EVALUATION INPUTS
- QUESTION (what the user asked): <question>
- RUBRIC CRITERION (what to check): <rubric_item>
- RESPONSE TO EVALUATE: <llm_response>
## EVALUATION RUBRIC:
The rubric defines a specific requirement, constraint, or expected behavior that the LLM response should demonstrate.
**IMPORTANT**: Pay careful attention to whether the rubric specifies:
- **Positive requirements** (things the response SHOULD include/do)
- **Negative constraints** (things the response SHOULD NOT include/do, often indicated by "no", "not", "avoid", "absent")
## RESPONSIVENESS REQUIREMENT (anchored to the QUESTION)
A compliant response must be **on-topic with respect to the QUESTION** and attempt to answer it.
- If the response does not address the QUESTION, score **0.0** and stop.
- For negative constraints, both must hold: (a) the response is responsive to the QUESTION, and (b) the prohibited element is absent.
## SEMANTIC TOLERANCE RULES:
Judge by meaning, not exact wording.
- Accept **paraphrases** and **synonyms** that preserve intent.
- **Case/punctuation/whitespace** differences must be ignored.
- **Numbers/currencies/dates** may appear in equivalent forms (e.g., "$68,000", "68k", "68,000 USD", or "sixty-eight thousand dollars"). Treat them as equal when numerically equivalent.
- If the rubric expects a number or duration, prefer **normalized comparison** (extract and compare values) over string matching.
## STYLE NEUTRALITY (prevents style contamination):
Ignore tone, politeness, length, and flourish unless the rubric explicitly requires a format/structure (e.g., "itemized list", "no citations", "one sentence").
- Do **not** penalize hedging, voice, or verbosity if content satisfies the rubric.
- Only evaluate format when the rubric **explicitly** mandates it.
## SCORING SCALE:
- **1.0 (Complete Compliance)**: Fully complies with the rubric criterion.
- Positive: required element present, accurate, properly executed (allowing semantic equivalents).
- Negative: prohibited element **absent** AND response is **responsive**.
- **0.5 (Partial Compliance)**: Partially complies.
- Positive: element present but minor inaccuracies/incomplete execution.
- Negative: generally responsive and mostly avoids the prohibited element but with minor/edge violations.
- **0.0 (No Compliance)**: Fails to comply.
- Positive: required element missing or incorrect.
- Negative: prohibited element present **or** response is non-responsive/evasive even if the element is absent.
## EVALUATION INSTRUCTIONS:
1. **Understand the Requirement**: Determine if the rubric is asking for something to be present (positive) or absent (negative/constraint).
2. **Parse Compound Statements**: If the rubric contains multiple elements connected by "and" or commas, evaluate whether:
- **All elements** must be present for full compliance (1.0)
- **Some elements** present indicates partial compliance (0.5)
- **No elements** present indicates no compliance (0.0)
3. **Check Compliance**:
- For positive requirements: Look for the presence and quality of the required element
- For negative constraints: Look for the absence of the prohibited element
4. **Assign Score**: Based on compliance with the specific rubric criterion according to the scoring scale above.
5. **Provide Reasoning**: Explain whether the rubric criterion was satisfied and justify the score.
## OUTPUT FORMAT:
Return your evaluation in JSON format with two fields:
{
"score": [your score: 1.0, 0.5, or 0.0],
"reason": "[detailed explanation of whether the rubric criterion was satisfied and why this justified the assigned score]"
}
NOTE: ONLY output the json object, without any explanation before or after that

View file

@ -1,21 +1,11 @@
"""LongMemEval benchmark steps."""
from .agentic_answer import LmeAgenticAnswerStep
from .llm_judge import LmeAnswerJudgeStep
from .auto_memory import LmeAutoMemoryStep
from .context_answer import ContextAnswerStep
from .extract_session import LmeExtractSessionStep
from .final_answer_review import FinalAnswerReviewStep
from .golden_check import GoldenCheckStep
from .lme_llm_judge import LmeLlmJudgeStep
from .session_review import SessionReviewStep
__all__ = [
"ContextAnswerStep",
"FinalAnswerReviewStep",
"GoldenCheckStep",
"LmeAgenticAnswerStep",
"LmeAnswerJudgeStep",
"LmeAutoMemoryStep",
"LmeExtractSessionStep",
"LmeLlmJudgeStep",
"SessionReviewStep",
]

View file

@ -1,87 +1,16 @@
"""agentic_answer — answer the LongMemEval question from the indexed memory.
"""LongMemEval agentic answer step – ReAct agent that answers questions using the search tool."""
Job #4 of the pipeline. Reads ``query.json`` and hands the question to an agent
equipped with ``vector_search`` / ``bm25_search`` / ``python_execute`` /
``extract_session_by_id``. The agent searches the daily-note index, pivots to
raw sessions by ``session_id`` when a hit is promising, and keeps trying until it
can answer or has searched too many times. The final answer is written to
``mem_answer.json`` in the workspace.
"""
import json
from ...base_step import BaseStep
from ....components import R
from ..base import BaseAgenticAnswerStep
@R.register("lme_agentic_answer_step")
class LmeAgenticAnswerStep(BaseStep):
"""Drive the tool-using agent that answers from indexed memory."""
class LmeAgenticAnswerStep(BaseAgenticAnswerStep):
"""Answer a LongMemEval query via ReAct agent with access to the search tool.
_OUTPUT_FILE = "mem_answer.json"
The agent uses the ``agent_wrapper`` component in ReAct mode, calling the
``search`` job tool to retrieve relevant memory chunks before generating
a final answer.
"""
def _load_query(self) -> dict:
path = self.workspace_path / "query.json"
with path.open(encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("query.json is not a JSON object")
return data
async def execute(self):
assert self.context is not None
if self.agent_wrapper is None:
raise ValueError("lme_agentic_answer_step requires agent_wrapper")
query = self._load_query()
question = str(query.get("question", "") or "").strip()
question_date = str(query.get("question_date", "") or "").strip()
question_id = str(query.get("question_id", "") or "").strip()
if not question:
raise ValueError("query.json requires a non-empty 'question'")
user_prompt = self.prompt_format(
"user_message",
question=question,
question_date=question_date or "(unknown)",
)
# A stable tool_context_id makes vector/bm25 dedup across this answer run,
# so repeated searches surface genuinely new chunks each time.
result = await self.agent_wrapper.reply(
user_prompt,
system_prompt=self.get_prompt("system_prompt"),
tool_context_id=question_id or question,
)
answer = (result.get("result") or "").strip()
# session_id names the trajectory file mem_session/agentscope/<session_id>.jsonl,
# so downstream tooling can locate this run's full tool-call trail.
session_id = str(result.get("session_id") or "")
out_path = self.workspace_path / self._OUTPUT_FILE
out_path.write_text(
json.dumps(
{
"question_id": question_id,
"question": question,
"answer": answer,
"session_id": session_id,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
self.logger.info(f"[{self.name}] answer for {question_id or question!r}: {answer!r}")
self.context.response.success = True
self.context.response.answer = answer
self.context.response.metadata.update(
{
"question_id": question_id,
"question": question,
"answer": answer,
"session_id": session_id,
"path": self._OUTPUT_FILE,
},
)
return self.context.response
TOOL_CONTEXT_PREFIX = "lme_agentic_answer"

View file

@ -1,40 +1,18 @@
system_prompt: |
You answer a user's question using ONLY their long-term memory, retrieved through tools. The
memory is a set of daily notes, each distilled from one past chat session and tagged in its
header with a `session_id` pointing back to the raw session.
You are a memory retrieval assistant. You MUST use the search tool to find information before answering.
- Your total time of tool calls should be at most 9 times
Available tools:
- `vector_search(query)`: dense semantic search over the memory notes.
- `bm25_search(query)`: keyword search over the memory notes.
- `extract_session_by_id(session_id)`: go back to the ORIGINAL raw session behind a note and
pull out its full content relevant to the question. Use the `session_id` shown in a search
result's header.
- `python_execute(code)`: run Python for any counting, date math, or reasoning over what you found.
## Search Strategy
- You can call 'search' tool to search multiple times (at least once) with different queries to gather comprehensive information.
- Do not stop after the first relevant one unless you are confident that the information is sufficient.
## Draft Tool
- Use 'add_draft' to save key findings during search, and 'read_all_draft' to review all saved notes before answering.
## Answer Rules
- Answer based ONLY on retrieved context.
- If the memories contain contradictory information, integrate the context, query, and most importantly the timestamp of each memory to comprehensively determine the final answer.
- Output ONLY the direct factual answer — no reasoning, no search process, no elaboration.
- Do NOT invent or assume information that isn't in the memories.
- If information is not founded or not sufficient after multiple searches, reply: 'Information not found.'
Strategy:
1. Start by searching with the user's ORIGINAL question wording — call BOTH `vector_search` and
`bm25_search` with it.
2. If the results already fully support an answer, answer.
3. If a result looks relevant but the distilled note is not enough (missing a number, exact date,
or wording), take its `session_id` and call `extract_session_by_id` to read the raw session.
4. Do NOT give up early. Keep trying: reformulate with new keywords, aliases, entities, dates, and
short phrases from the question or from earlier results; search again; and call
`extract_session_by_id` on any additional relevant sessions.
5. Search results are deduplicated within this run, so a search returning nothing new means those
chunks were already seen — change your wording rather than repeating it.
6. Only after you have made MORE THAN 10 search attempts and still cannot find support, answer
exactly: not provided
Answer rules:
- Answer strictly from retrieved memory; never invent facts.
- Be direct and specific; include the exact value/date the question asks for.
- Your final message is the answer itself (no tool calls, no preamble).
user_message: |
Question date: {question_date}
Question: {question}
Find the answer in the user's long-term memory using the tools, following the strategy above.
Remember: search with the original question first (both vector and bm25), pivot to
`extract_session_by_id` for promising sessions, keep trying with new wording, and only answer
"not provided" after more than 10 search attempts have failed.
temporal_hint: |
Current time context: {query_time}

View file

@ -1,428 +1,132 @@
"""lme_auto_memory — turn every LongMemEval session into a search-friendly note.
"""auto_memory (lme) — AutoMemoryStep with timestamp interpolation and daily_write date default."""
For a workspace such as ``datasets/longmemeval/1`` this step walks each raw
session under ``resource_dir`` (files named ``<date>_(...)_<time>@<session_id>.json``
with ``haystack_date`` / ``haystack_session_id`` / ``messages``) and, one per
session, asks an agent to *completely* extract its content — entities, times,
numbers, preferences, events, causal links — into a daily note optimized for
both BM25 and vector retrieval.
from datetime import datetime, timedelta
Each note is written to ``<daily_dir>/<YYYY-MM-DD>/<name>.md`` via the shared
``daily_write`` job, so the frontmatter carries ``session_id`` for progressive
expansion (the agentic-answer flow pivots from a search hit back to the raw
session through this id). Filenames are LLM-generated topic stems; same-day
collisions are disambiguated by appending the session id.
"""
from agentscope.message import Msg
import asyncio
import json
import re
import time
from datetime import datetime
from pathlib import Path
import frontmatter
from ...base_step import BaseStep
from ...file_io import extract_daily_date
from ...evolve.auto_memory import AutoMemoryStep, _normalize_msg_timestamp
from ....components import R
START_INTERVAL_SECONDS = 1.0
MAX_CONCURRENCY = 60
RETRY_INITIAL_SECONDS = 5.0
RETRY_MAX_SECONDS = 300.0
_LME_DATETIME_RE = re.compile(r"(\d{4})/(\d{2})/(\d{2}).*?(\d{2}):(\d{2})")
_NON_RETRYABLE_DATA_INSPECTION_MARKERS = (
"data_inspection_failed",
"DataInspectionFailed",
"Input text data may contain inappropriate content",
)
# Structured extraction the memory agent must return per session.
_MEMORY_SCHEMA = {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Concise, stable topic/event filename stem (kebab-case, no date, no slash or "
"reserved characters). E.g. 'daily-commute-details' or 'leather-boot-care'.",
},
"description": {
"type": "string",
"description": "Thorough one-paragraph summary of the note body — specific enough that this "
"description alone conveys all key facts. Used as a search-friendly abstract.",
},
"body": {
"type": "string",
"description": "Complete markdown extraction of every core fact in the session, written for "
"retrieval (natural-language statements, explicit entities, dates and numbers verbatim).",
},
},
"required": ["name", "description", "body"],
}
def _parse_iso_seconds(value: str) -> datetime | None:
"""Parse an ISO-8601 timestamp that is precise to at least seconds.
Accepts formats like:
2026-07-01T14:30:00
2026-07-01T14:30:00Z
2026-07-01T14:30:00+08:00
2026-07-01T14:30:00.123456
Rejects date-only (``2026-07-01``) or minute-only (``2026-07-01T14:30``).
Returns ``None`` when the value does not satisfy the requirements.
"""
text = str(value).strip()
# Minimum valid: YYYY-MM-DDTHH:MM:SS = 19 chars
if len(text) < 19:
return None
# Must contain 'T' separator and at least HH:MM:SS after it
if "T" not in text:
return None
time_part = text.split("T", 1)[1]
# time_part must start with HH:MM:SS (8 chars minimum)
if len(time_part) < 8 or time_part[2] != ":" or time_part[5] != ":":
return None
try:
return datetime.fromisoformat(text)
except (ValueError, TypeError):
return None
def _interpolate_timestamps(items: list[dict]) -> list[dict]:
"""Fill missing ``created_at`` fields via linear interpolation.
Rules (backward-compatible — returns *items* unchanged when no message
carries a ``created_at`` value):
1. If **no** message has ``created_at`` → return as-is (system time used
later by AgentScope's ``Msg`` constructor).
2. Messages **before** the first timestamped message → inherit the first
timestamp.
3. Messages **after** the last timestamped message → inherit the last
timestamp.
4. Messages **between** two timestamped anchors → linearly interpolated.
"""
# Pass 1: normalize aliases and collect anchors
normalized: list[dict] = []
anchors: list[tuple[int, datetime]] = [] # (index, parsed_dt)
for i, raw_item in enumerate(items):
if not isinstance(raw_item, dict):
normalized.append(raw_item)
continue
item = _normalize_msg_timestamp(raw_item)
normalized.append(item)
ca = item.get("created_at")
if ca:
dt = _parse_iso_seconds(str(ca))
if dt is not None:
anchors.append((i, dt))
# No anchors → fully backward-compatible, let Msg use system time
if not anchors:
return normalized
# Pass 2: interpolate
result: list[dict] = []
for i, item in enumerate(normalized):
if not isinstance(item, dict):
result.append(item)
continue
# Already has a valid parsed anchor — keep it
if any(idx == i for idx, _ in anchors):
result.append(item)
continue
# Find the nearest preceding and following anchors
prev_anchor: tuple[int, datetime] | None = None
next_anchor: tuple[int, datetime] | None = None
for idx, dt in anchors:
if idx < i:
prev_anchor = (idx, dt)
for idx, dt in anchors:
if idx > i:
next_anchor = (idx, dt)
break
# Determine interpolated time
if prev_anchor is None:
# Before the first anchor
interpolated_dt = anchors[0][1]
elif next_anchor is None:
# After the last anchor
interpolated_dt = anchors[-1][1]
else:
# Between two anchors — linear
prev_idx, prev_dt = prev_anchor
next_idx, next_dt = next_anchor
span = next_idx - prev_idx
ratio = (i - prev_idx) / span
delta_seconds = (next_dt - prev_dt).total_seconds()
interpolated_dt = prev_dt + timedelta(seconds=delta_seconds * ratio)
item = {**item, "created_at": interpolated_dt.isoformat()}
result.append(item)
return result
@R.register("lme_auto_memory_step")
class LmeAutoMemoryStep(BaseStep):
"""Extract each LME session into a daily note via a per-session agent."""
class LmeAutoMemoryStep(AutoMemoryStep):
"""AutoMemoryStep variant that interpolates timestamps and pins daily_write to the resolved day."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._reserve_lock = asyncio.Lock()
self._reserved: dict[tuple[str, str], str] = {}
def _resource_dir_name(self) -> str:
return self.app_context.app_config.resource_dir if self.app_context is not None else "session"
def _session_dir(self) -> Path:
return self.workspace_path / self._resource_dir_name()
@staticmethod
def _parse_lme_datetime(raw_date: str) -> datetime | None:
"""Parse LongMemEval timestamps like ``2023/05/20 (Sat) 03:29``."""
match = _LME_DATETIME_RE.search(raw_date.strip())
if match is None:
return None
try:
year, month, day, hour, minute = (int(part) for part in match.groups())
return datetime(year, month, day, hour, minute)
except ValueError:
return None
@staticmethod
def _parse_day(raw_date: str) -> str | None:
"""Parse a LongMemEval ``haystack_date`` (e.g. '2023/05/20 (Sat) 03:29') to YYYY-MM-DD."""
head = raw_date.strip()[:10].replace("/", "-")
return extract_daily_date(head)
@staticmethod
def _load_json(path: Path) -> dict:
with path.open(encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("session file is not a JSON object")
return data
@staticmethod
def _format_messages(messages: list) -> str:
lines: list[str] = []
for msg in messages:
if not isinstance(msg, dict):
continue
role = str(msg.get("role", "")).strip() or "unknown"
content = msg.get("content", "")
if not isinstance(content, str):
content = json.dumps(content, ensure_ascii=False)
lines.append(f"[{role}]\n{content}")
return "\n\n".join(lines)
@staticmethod
def _is_data_inspection_error(exc: Exception) -> bool:
text = str(exc)
return any(marker in text for marker in _NON_RETRYABLE_DATA_INSPECTION_MARKERS)
async def _existing_session_id(self, rel_path: str) -> str:
note = self.workspace_path / rel_path
if not note.is_file():
return ""
try:
post = frontmatter.loads(note.read_text(encoding="utf-8"))
except Exception:
return ""
return str((post.metadata or {}).get("session_id", "") or "").strip()
async def _reserve_name(self, daily_dir: str, day: str, name: str, session_id: str) -> str:
"""Pick a collision-free filename stem for this session under ``day``."""
async with self._reserve_lock:
for cand in (name, f"{name}-{session_id}"):
key = (day, cand)
owner = self._reserved.get(key)
if owner == session_id:
return cand
if owner is not None:
continue
existing = await self._existing_session_id(f"{daily_dir}/{day}/{cand}.md")
if existing and existing != session_id:
continue
self._reserved[key] = session_id
return cand
# Extremely unlikely fallback (same topic AND same session id twice).
i = 2
while True:
cand = f"{name}-{session_id}-{i}"
key = (day, cand)
if key not in self._reserved:
self._reserved[key] = session_id
return cand
i += 1
# pylint: disable=too-many-statements
async def execute(self):
assert self.context is not None
if self.agent_wrapper is None:
raise ValueError("lme_auto_memory_step requires agent_wrapper")
query_data = self._load_json(self.workspace_path / "query.json")
question_date = str(query_data.get("question_date") or "").strip()
question_dt = self._parse_lme_datetime(question_date)
if question_dt is None:
raise ValueError(f"query.json has an invalid 'question_date': {question_date!r}")
session_dir = self._session_dir()
if not session_dir.is_dir():
raise FileNotFoundError(f"Session directory not found: {session_dir}")
session_files = sorted(p for p in session_dir.iterdir() if p.suffix == ".json")
sessions: list[tuple[dict, Path, str, str, str]] = []
filtered_sessions: list[dict] = []
session_ids_illegal: list[str] = []
for session_path in session_files:
try:
session = self._load_json(session_path)
except (ValueError, OSError) as exc:
self.logger.warning(f"[{self.name}] skip {session_path.name}: {exc}")
continue
session_id = str(session.get("haystack_session_id") or session_path.stem)
session_date = str(session.get("haystack_date") or "").strip()
session_dt = self._parse_lme_datetime(session_date)
if session_dt is not None and session_dt > question_dt:
session_ids_illegal.append(session_id)
filtered_sessions.append(
{
"session_id": session_id,
"session_date": session_date,
"session_file": session_path.name,
"reason": "session_date_after_question_date",
},
)
continue
if session_dt is None:
self.logger.warning(
f"[{self.name}] keep {session_id}: cannot parse haystack_date={session_date!r}",
)
day = session_dt.strftime("%Y-%m-%d") if session_dt is not None else (self._parse_day(session_date) or "")
sessions.append((session, session_path, session_id, session_date, day))
daily_dir = self.config_value("daily_dir")
resource_dir = self._resource_dir_name()
start_interval_seconds = float(self.kwargs.get("start_interval_seconds", START_INTERVAL_SECONDS))
if start_interval_seconds < 0:
start_interval_seconds = START_INTERVAL_SECONDS
concurrency = int(self.kwargs.get("concurrency", MAX_CONCURRENCY))
if concurrency <= 0:
concurrency = MAX_CONCURRENCY
concurrency = min(concurrency, MAX_CONCURRENCY)
total = len(sessions)
self.logger.info(
f"[{self.name}] extracting {total} sessions from {session_dir} "
f"(filtered {len(session_ids_illegal)} sessions after question_date, "
f"start_interval={start_interval_seconds}s, concurrency={concurrency})",
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
# time ordering (see _interpolate_timestamps docstring for rules).
interpolated = _interpolate_timestamps(
[item if not isinstance(item, dict) else dict(item) for item in raw_messages],
)
return [self._to_msg(item) for item in interpolated]
self._reserved.clear()
failed_extracts: list[dict] = []
retry_initial_seconds = float(self.kwargs.get("retry_initial_seconds", RETRY_INITIAL_SECONDS))
retry_max_seconds = float(self.kwargs.get("retry_max_seconds", RETRY_MAX_SECONDS))
retry_max_attempts_raw = self.kwargs.get("retry_max_attempts")
retry_max_attempts = int(retry_max_attempts_raw) if retry_max_attempts_raw not in (None, "") else 0
if retry_initial_seconds <= 0:
retry_initial_seconds = RETRY_INITIAL_SECONDS
retry_max_seconds = max(retry_max_seconds, retry_initial_seconds)
retry_gate = asyncio.Condition()
retry_sleeping_extract_idxs: set[int] = set()
submit_lock = asyncio.Lock()
last_submitted_at = 0.0
semaphore = asyncio.Semaphore(concurrency)
def has_prior_retry_sleeping(idx: int) -> bool:
return any(retry_idx < idx for retry_idx in retry_sleeping_extract_idxs)
async def wait_for_start_slot() -> None:
nonlocal last_submitted_at
async with submit_lock:
sleep_seconds = last_submitted_at + start_interval_seconds - time.monotonic()
if sleep_seconds > 0:
await asyncio.sleep(sleep_seconds)
last_submitted_at = time.monotonic()
async def wait_for_healthy_start_slot(idx: int, session_id: str) -> None:
while True:
async with retry_gate:
if has_prior_retry_sleeping(idx):
self.logger.info(
f"[{self.name}] ({idx}/{total}) {session_id} waits for earlier retry sleep",
)
await retry_gate.wait_for(lambda: not has_prior_retry_sleeping(idx))
await wait_for_start_slot()
async with retry_gate:
if not has_prior_retry_sleeping(idx):
return
async def mark_retry_sleeping(idx: int) -> None:
async with retry_gate:
retry_sleeping_extract_idxs.add(idx)
retry_gate.notify_all()
async def mark_retry_awake(idx: int) -> None:
async with retry_gate:
retry_sleeping_extract_idxs.discard(idx)
retry_gate.notify_all()
async def reply_with_retry(idx: int, user_prompt: str, session_id: str) -> dict:
attempt = 1
sleep_seconds = retry_initial_seconds
while True:
try:
await wait_for_healthy_start_slot(idx, session_id)
result = await self.agent_wrapper.reply(
user_prompt,
system_prompt=self.get_prompt("system_prompt"),
output_schema=_MEMORY_SCHEMA,
)
if not isinstance(result.get("structured_output"), dict):
raise ValueError("agent reply missing structured_output")
await mark_retry_awake(idx)
if attempt > 1:
self.logger.info(f"[{self.name}] extract recovered for {session_id} after {attempt} attempts")
return result
except Exception as exc:
if self._is_data_inspection_error(exc):
await mark_retry_awake(idx)
raise
if 0 < retry_max_attempts <= attempt:
await mark_retry_awake(idx)
raise
await mark_retry_sleeping(idx)
next_sleep = min(sleep_seconds, retry_max_seconds)
self.logger.warning(
f"[{self.name}] extract attempt {attempt} failed for {session_id}: {exc}; "
f"retrying in {next_sleep:.1f}s",
)
await asyncio.sleep(next_sleep)
await mark_retry_awake(idx)
sleep_seconds = min(sleep_seconds * 2, retry_max_seconds)
attempt += 1
async def extract_one(
idx: int,
session: dict,
session_path: Path,
session_id: str,
session_date: str,
day: str,
) -> dict | None:
if not day:
self.logger.warning(f"[{self.name}] skip {session_id}: unparseable date {session_date!r}")
return None
messages = session.get("messages") or []
user_prompt = self.prompt_format(
"user_message",
session_id=session_id,
session_date=session_date,
messages=self._format_messages(messages),
)
try:
result = await reply_with_retry(idx, user_prompt, session_id)
except Exception as exc: # noqa: BLE001 — one bad session must not abort the sweep
if self._is_data_inspection_error(exc):
self.logger.warning(
f"[{self.name}] extract fallback for {session_id}: non-retryable data inspection error",
)
failed_extracts.append(
{
"session_id": session_id,
"session_date": session_date,
"session_file": session_path.name,
"error": str(exc),
"non_retryable": True,
"fallback": True,
"fallback_reason": "data_inspection_failed",
"raw_session": session,
},
)
return None
self.logger.warning(f"[{self.name}] extract failed for {session_id}: {exc}")
failed_extracts.append(
{
"session_id": session_id,
"session_date": session_date,
"session_file": session_path.name,
"error": str(exc),
"non_retryable": False,
"fallback": False,
},
)
return None
extracted = result.get("structured_output")
name = description = body = ""
if isinstance(extracted, dict):
name = str(extracted.get("name") or "").strip()
description = str(extracted.get("description") or "").strip()
body = str(extracted.get("body") or "").strip()
if not isinstance(extracted, dict) or not name or not body:
if isinstance(extracted, dict):
self.logger.info(f"[{self.name}] empty extraction for {session_id}; skipping")
else:
self.logger.warning(f"[{self.name}] no structured output for {session_id}; skipping")
return None
unique_name = await self._reserve_name(daily_dir, day, name, session_id)
rel_path = f"{daily_dir}/{day}/{unique_name}.md"
post = frontmatter.Post(
body,
name=unique_name,
description=description,
session_id=session_id,
session_date=session_date,
source=f"[[{resource_dir}/{session_path.name}]]",
)
abs_path = self.workspace_path / rel_path
abs_path.parent.mkdir(parents=True, exist_ok=True)
abs_path.write_text(frontmatter.dumps(post), encoding="utf-8")
self.logger.info(f"[{self.name}] ({idx}/{total}) {session_id} -> {rel_path}")
return {"session_id": session_id, "date": day, "path": rel_path}
async def extract_one_limited(
idx: int,
session: dict,
session_path: Path,
session_id: str,
session_date: str,
day: str,
) -> dict | None:
async with semaphore:
return await extract_one(idx, session, session_path, session_id, session_date, day)
results = await asyncio.gather(
*(
extract_one_limited(idx, session, session_path, session_id, session_date, day)
for idx, (session, session_path, session_id, session_date, day) in enumerate(sessions, start=1)
),
)
written = [r for r in results if r is not None]
fallback_extracts = [e for e in failed_extracts if e.get("fallback")]
self.context.response.success = True
self.context.response.answer = f"wrote {len(written)}/{total} session notes"
self.context.response.metadata.update(
{
"num_sessions": total,
"num_session_files": len(session_files),
"num_written": len(written),
"num_failed_extracts": len(failed_extracts),
"num_fallback_extracts": len(fallback_extracts),
"num_filtered_sessions": len(session_ids_illegal),
"session_ids_illegal": session_ids_illegal,
"filtered_sessions": filtered_sessions,
"failed_extracts": failed_extracts,
"fallback_extracts": fallback_extracts,
"notes": written,
},
)
return self.context.response
def _reply_extra_kwargs(self, day: str) -> dict:
return {"tool_defaults": {"daily_write": {"date": day}}}

View file

@ -1,45 +1,334 @@
system_prompt: |
You are a memory extractor for the LongMemEval benchmark. You are given ONE chat session
between a user and an assistant. Your job is to extract its content COMPLETELY into a single
daily memory note that will later be retrieved by both BM25 keyword search and dense vector
search. Completeness and retrievability are the only goals — do NOT compress or editorialize.
You are an automatic memory extraction system. Your task is to extract ALL valuable information from conversations and record them into a structured daily memory note. Ensure **complete coverage** — every piece of information worth preserving must be captured.
## What to extract — everything that could ever be asked later
Capture every core fact stated or clearly implied in the session, including but not limited to:
- Facts about the user: identity, preferences, habits, possessions, relationships, plans, goals.
- Events and actions: what happened, what was decided, what the user did or intends to do.
- Entities: people, places, organizations, products, titles, brands — with their exact names.
- Numbers and quantities: durations, distances, prices, counts, measurements — verbatim.
- Time information ABOVE ALL: absolute dates, weekdays, and relative expressions ("last week",
"since January 15th", "every morning", "for 3 years"). Always keep the fact together with its
time expression, and when possible also anchor it to the session date.
## Extraction Principles
Do not invent anything. Only record what is actually in the session. If the session is pure
small talk with no durable facts, still produce a minimal faithful note (do not fabricate).
1. **Complete Coverage**: Generate enough content to ensure ALL information in the dialogues is captured. Missing information is worse than redundancy.
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.
## How to write the body — optimize for search
- Write plain natural-language declarative sentences (one fact per sentence or bullet). Dense
retrievers embed sentences well; BM25 matches exact tokens — so both benefit from full,
unabbreviated wording.
- State entities, dates and numbers explicitly and verbatim; expand abbreviations and also
include common aliases/synonyms the user or assistant used, so keyword search can hit them.
- Prefer the user's own key phrasing at important points (quote short fragments verbatim).
- Use markdown structure (headings/bullets) freely, but never drop a fact for the sake of brevity.
## What to Extract
## Frontmatter fields you return
- `name`: a concise, stable, kebab-case topic/event stem (no date, no slashes or reserved
characters). It is only a filename — the searchable content lives in `description` and `body`.
- `description`: a thorough, search-friendly abstract that on its own conveys all key facts.
- `body`: the complete extraction as described above.
- **Persons**: All person names mentioned (full names when available)
- **Entities**: Companies, products, organizations, projects, repositories
- **Decisions & Events**: What happened, what was decided, and why
- **Facts & Preferences**: Persistent facts about the user — identity, habits, preferences, constraints
- **Procedures & Solutions**: Actionable steps, commands, configurations that can be directly reused
- **Current State**: Progress, blockers, next steps, open questions
- **Temporal Information**: Use absolute dates in ISO 8601 format (YYYY-MM-DD) when explicit time is mentioned
- **Locations**: Specific location names when mentioned
- **Topic Keywords**: Core keywords that identify the subject matter
user_message: |
Session id: {session_id}
Session date: {session_date}
## Body Format
--- Session messages ---
{messages}
--- End of session ---
Use structured sections with headings. Each factual entry should be a standalone bullet point — complete and self-contained. Group entries by topic. Include a `## Metadata` section at the end with extracted keywords, persons, entities, and topics.
Extract this session COMPLETELY into one search-friendly daily note. Keep every entity, number,
and (above all) every time expression verbatim, each attached to the fact it belongs to. Return
`name`, `description`, and `body`.
## Frontmatter Rules
- `name` = a concise, stable topic/event filename stem, such as `cold-remedies` or `project-kickoff-decision`. Do not include today's date or the daily directory date; the outer daily path already records the date. For existing notes, update it when a better filename is clearly warranted.
- `description` = a thorough, lossless summary; vague descriptions like "notes" / "misc" are unacceptable. The description alone should convey all key information.
- **Never set `status`** — it is a field reserved for downstream processing.
## Tool Call Format — CRITICAL
You have access to tools (e.g. `daily_write`, `read`, `edit`, `write`, `frontmatter_update`). You MUST invoke them as **structured tool calls** (function calls), NOT as plain text output.
- **NEVER** write tool invocations as text strings such as `daily_write name=...`. Text output does NOT execute tools — no memory will be saved.
- **ALWAYS** use the tool call mechanism provided by the runtime. The system handles parameter passing automatically.
- If you write a tool name and its parameters as text, NO tool will be executed and NO memory will be saved.
system_prompt_zh: |
你是自动记忆提取系统。你的任务是从对话中提取所有有价值的信息,并记录到结构化的日记记忆中。确保**完整覆盖**——每一条值得保留的信息都必须被捕获。
## 提取原则
1. **完整覆盖**:生成足够的内容以确保对话中的所有信息都被捕获。遗漏信息比冗余更糟。
2. **强制消歧**:绝对禁止使用代词(他、她、它、他们、这个、那个)或相对时间(昨天、今天、上周、明天、最近)。必须使用全名、明确日期和具体引用。
3. **无损信息**:每条记录的事实必须是完整的、独立的、可理解的陈述。没有上下文的读者必须能单独理解每条记录。
4. **原文保留**:逐字引用原始措辞、数字、URL、命令和代码片段。
## 提取什么
- **人物**:提及的所有人名(尽量使用全名)
- **实体**:公司、产品、组织、项目、仓库
- **决策与事件**:发生了什么、决定了什么、为什么
- **事实与偏好**:关于用户的持久事实——身份、习惯、偏好、约束
- **流程与方案**:可直接复用的操作步骤、命令、配置
- **当前状态**:进度、卡点、下一步、未决问题
- **时间信息**:当对话中明确提及时间时,使用 ISO 8601 绝对日期格式(YYYY-MM-DD)
- **地点**:提及的具体地点名称
- **主题关键词**:标识主题的核心关键词
## 正文格式
使用带标题的结构化分节。每条事实记录应是独立的列表项——完整且自包含。按主题分组。在末尾添加 `## Metadata` 部分,列出提取的关键词、人物、实体和主题。
## Frontmatter 规则
- `name` = 简洁、稳定的主题/事件文件名 stem,例如 `cold-remedies` 或 `project-kickoff-decision`。不要包含今天日期或日记目录日期;外层日记路径已经记录日期。对已有笔记,如果明显有更好的文件名,就更新它。
- `description` = 详尽的无损总结;模糊的描述如 "notes" / "misc" 不可接受。仅凭 description 就应能传达全部核心信息。
- **永远不要设置 `status`**——它是下游处理保留的字段。
## 工具调用格式 — 关键
你可以使用工具(如 `daily_write`、`read`、`edit`、`write`、`frontmatter_update`)。你**必须**以**结构化工具调用**(函数调用)的方式调用它们,而不是以纯文本输出。
- **绝不**将工具调用作为文本字符串输出,如 `daily_write name=...`。文本输出**不会**执行工具——不会保存任何记忆。
- **始终**使用运行时提供的工具调用机制。系统会自动处理参数传递。
- 如果你将工具名和参数作为文本输出,**不会有任何工具被执行**,也**不会**保存任何记忆。
user_message_create: |
Today: {today}
Extra hint: {note}
Session ID: {session_id}
# Recent Conversation
{history}
# Your Task
Extract all valuable information from the conversation above and record them into a new daily memory note.
## Step 1 — Skip Check
Did the conversation produce substantive information worth long-term memory? Pure greetings or small talk → reply with a brief skip message and stop (do not call any tools).
When truly ambiguous, default to writing — losing a memory is worse than writing one extra note.
## Step 2 — Extract & Write
Analyze the conversation and extract:
- **keywords**: Core keywords (names, places, entities, topic words)
- **persons**: All person names mentioned
- **entities**: Companies, products, organizations, projects
- **topic**: The main topic(s) of the conversation
- **timestamp**: Absolute time in ISO 8601 format (if explicit time mentioned)
- **location**: Specific location name (if mentioned)
Then create the note by **calling the `daily_write` tool** with these parameters:
- `name`: <a concise, stable topic/event filename stem in kebab-case>
- `description`: <a thorough, lossless summary>
- `session_id`: {session_id}
- `date`: {today}
- `content`: <the full body text>
⚠️ 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:
- Each factual entry must be a complete, independent, self-contained statement (lossless restatement).
- PROHIBIT pronouns (he/she/it/they/this/that) — use full names and explicit references.
- PROHIBIT relative time (yesterday/today/last week) — use absolute dates (YYYY-MM-DD).
- Group entries by topic with headings.
- End with a `## Metadata` section containing: keywords, persons, entities, topic.
Filename and description rules:
- `name`: concise, stable topic/event filename stem in kebab-case. No date, no slash/backslash, no `< > : " | ? *`.
- `description`: a thorough, lossless summary — specific enough that the description alone conveys all key information.
## Step 3 — Summary
State in one sentence what you did (which file was created). This is your final text output.
## Boundaries
- Create at most 3 notes for this session. If the conversation covers multiple distinct topics, split them into separate notes (one topic per note). If only one topic is discussed, create just one note.
user_message_create_zh: |
今天:{today}
额外提示:{note}
Session ID:{session_id}
# 最近的对话
{history}
# 你的任务
从上述对话中提取所有有价值的信息,并记录到一条新的日记记忆中。
## 步骤 1 — 跳过检查
对话是否产生了值得长期记忆的实质性信息?纯粹的寒暄或闲聊 → 回复一条简短的跳过消息并停止(不调用任何工具)。
当真正模棱两可时,默认写入——丢失记忆比多写一条笔记更糟。
## 步骤 2 — 提取并写入
分析对话并提取:
- **keywords**:核心关键词(人名、地点、实体、主题词)
- **persons**:提及的所有人名
- **entities**:公司、产品、组织、项目
- **topic**:对话的主要主题
- **timestamp**:ISO 8601 格式的绝对时间(如果对话中明确提及时间)
- **location**:具体地点名称(如果提及)
然后**调用 `daily_write` 工具**,使用以下参数一次性创建笔记:
- `name`:<简洁、稳定的主题/事件文件名 stem,采用 kebab-case>
- `description`:<详尽的无损总结>
- `session_id`:{session_id}
- `date`:{today}
- `content`:<完整正文>
⚠️ 关键:你**必须**以**工具调用**(函数调用)方式调用 `daily_write`。不要将 `daily_write name=...` 作为纯文本输出——那样**不会**执行工具,也**不会**保存任何记忆。
正文要求:
- 每条事实记录必须是完整的、独立的、自包含的陈述(无损复述)。
- 禁止使用代词(他/她/它/他们/这个/那个)——必须使用全名和明确引用。
- 禁止使用相对时间(昨天/今天/上周)——必须使用绝对日期(YYYY-MM-DD)。
- 按主题分组并使用标题。
- 末尾添加 `## Metadata` 部分,包含:keywords、persons、entities、topic。
文件名和描述规则:
- `name`:简洁、稳定的主题/事件文件名 stem,采用 kebab-case。不含日期、不含 slash/反斜杠、不含 `< > : " | ? *`。
- `description`:详尽的无损总结——具体到仅凭 description 就能传达全部核心信息。
## 步骤 3 — 总结
用一句话说明你做了什么(创建了哪个文件)。这是你最后一次文本输出。
## 边界
- 当前 session 最多创建 3 条笔记。如果对话涵盖多个不同主题,将其拆分为独立笔记(每个主题一条)。如果只讨论了一个主题,则只创建一条笔记。
user_message_update: |
Today: {today}
Extra hint: {note}
Target path: {note_path}
# Recent Conversation
{history}
# Your Task
Extract all valuable information from the conversation above and merge them into the existing daily note at the target path.
## Step 1 — Skip Check
Did the conversation produce substantive information worth long-term memory? Pure greetings or small talk → reply with a brief skip message and stop (do not call any tools).
When truly ambiguous, default to writing — losing a memory is worse than writing one extra note.
## Step 2 — Read Existing Content
**Call the `read` tool** with parameter `path` set to `{note_path}` to inspect the current note content. You MUST invoke it as a tool call, not as text.
- If the body is empty (only frontmatter, no actual content) → treat as new, jump to **Step 3b**.
- If there is body content → go to **Step 3a** to merge.
## Step 3a — Merge Update
The note already has content. Extract new information from the conversation and merge into it.
Extraction rules (apply to ALL new entries):
- Each new fact must be a complete, independent, self-contained statement (lossless restatement).
- PROHIBIT pronouns (he/she/it/they/this/that) — use full names and explicit references.
- PROHIBIT relative time (yesterday/today/last week) — use absolute dates (YYYY-MM-DD).
Merge rules:
- **Timeline / history entries**: append only, never delete existing entries.
- **Current-state entries** (progress, blockers, next steps, open questions): rewrite the entire section to reflect the latest snapshot.
- **Everything else**: merge and deduplicate — keep all old facts, add new facts, remove exact duplicates.
- **Metadata section**: merge keywords, persons, entities, topics — union of old and new values.
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.
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`.
3. If `edit` fails repeatedly (e.g., cannot find the original text due to formatting mismatch), fall back to **calling the `write` tool** with parameters: `path`="{note_path}", `name`=<name>, `description`=<description>, `content`=<full body> for a complete rewrite.
## Step 3b — Full Write (Empty File Fallback)
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:
- Each factual entry must be a complete, independent, self-contained statement.
- PROHIBIT pronouns — use full names and explicit references.
- PROHIBIT relative time — use absolute dates (YYYY-MM-DD).
- Group entries by topic with headings.
- End with a `## Metadata` section containing: keywords, persons, entities, topic.
Filename and description rules:
- `name`: concise, stable topic/event filename stem in kebab-case. No date.
- `description`: a thorough, lossless summary — the description alone should convey all key information.
## Step 4 — Summary
State in one sentence what you did (what content was updated). This is your final text output.
## Boundaries
- Only operate on one target path: `{note_path}`. Do not touch other notes.
- `write` unconditionally overwrites body and frontmatter — use with caution.
- Filename changes are expressed by updating frontmatter `name`.
user_message_update_zh: |
今天:{today}
额外提示:{note}
目标路径:{note_path}
# 最近的对话
{history}
# 你的任务
从上述对话中提取所有有价值的信息,并合并到目标路径的已有日记中。
## 步骤 1 — 跳过检查
对话是否产生了值得长期记忆的实质性信息?纯粹的寒暄或闲聊 → 回复一条简短的跳过消息并停止(不调用任何工具)。
当真正模棱两可时,默认写入——丢失记忆比多写一条笔记更糟。
## 步骤 2 — 读取现有内容
**调用 `read` 工具**,参数 `path` 设为 `{note_path}`,查看当前笔记内容。必须以工具调用方式调用,不要作为文本输出。
- 如果正文为空(只有 frontmatter 无实际内容)→ 按新建处理,跳到 **步骤 3b**。
- 如果有正文内容 → 转到 **步骤 3a** 进行合并。
## 步骤 3a — 合并更新
笔记已有内容。从对话中提取新信息并合并进去。
提取规则(适用于所有新条目):
- 每条新事实必须是完整的、独立的、自包含的陈述(无损复述)。
- 禁止使用代词(他/她/它/他们/这个/那个)——必须使用全名和明确引用。
- 禁止使用相对时间(昨天/今天/上周)——必须使用绝对日期(YYYY-MM-DD)。
合并规则:
- **时间线 / 历史条目**:仅追加,永远不删除已有条目。
- **当下状态类条目**(进度、卡点、下一步、未决问题):整段重写,反映最新快照。
- **其余内容**:合并去重——保留全部旧事实,添加新事实,去除完全重复项。
- **Metadata 部分**:合并 keywords、persons、entities、topics——取旧值和新值的并集。
执行:
1. **调用 `edit` 工具**更新需要修改的部分,参数为:`path`="{note_path}"、`old`=<原文片段>、`new`=<替换片段>。可以多次调用 `edit`。每次调用必须是工具调用,不是文本。
2. 正文变更后,**调用 `frontmatter_update` 工具**刷新 frontmatter,参数为:`path`="{note_path}"、`metadata`={{"name": "<更新后的文件名 stem>", "description": "<更新后的总结>"}}。
- 只有当前 `name` 已经是最合适的简洁主题/事件文件名 stem 时才保留。系统会在你最终回复后负责重命名文件。
- 不要在 `name` 中加入今天日期或日记目录日期。
3. 如果 `edit` 多次失败(如因格式不匹配找不到原文),退回**调用 `write` 工具**全量重写,参数为:`path`="{note_path}"、`name`=<name>、`description`=<description>、`content`=<完整正文>。
## 步骤 3b — 全量写入(空文件 fallback)
文件存在但正文为空。提取并**调用 `write` 工具**一次性写入完整内容,参数为:`path`="{note_path}"、`name`=<name>、`description`=<description>、`content`=<正文>。
正文要求:
- 每条事实记录必须是完整的、独立的、自包含的陈述。
- 禁止使用代词——必须使用全名和明确引用。
- 禁止使用相对时间——必须使用绝对日期(YYYY-MM-DD)。
- 按主题分组并使用标题。
- 末尾添加 `## Metadata` 部分,包含:keywords、persons、entities、topic。
文件名和描述规则:
- `name`:简洁、稳定的主题/事件文件名 stem,采用 kebab-case。不含日期。
- `description`:详尽的无损总结——仅凭 description 就应能传达全部核心信息。
## 步骤 4 — 总结
用一句话说明你做了什么(更新了哪些内容)。这是你最后一次文本输出。
## 边界
- 只针对一个目标路径:`{note_path}`。不要碰其他笔记。
- `write` 会无条件覆盖正文和 frontmatter,请谨慎使用。
- 文件名变化通过更新 frontmatter `name` 表达。

View file

@ -1,45 +0,0 @@
"""Answer a query directly from the supplied session context."""
from ...base_step import BaseStep
from ....components import R
@R.register("context_answer_step")
class ContextAnswerStep(BaseStep):
"""Answer a query using the LongMemEval direct-reading prompt."""
async def execute(self):
assert self.context is not None
query: str = self.context.get("query", "")
session_context: str = self.context.get("session_context", "")
current_date: str = self.context.get("current_date", "")
if not query:
raise ValueError("context_answer_step requires non-empty query")
if not session_context:
raise ValueError("context_answer_step requires non-empty session_context")
if self.agent_wrapper is None:
raise ValueError("context_answer_step requires agent_wrapper")
user_prompt = self.prompt_format(
"user_message",
session_context=session_context,
current_date=current_date,
query=query,
)
result = await self.agent_wrapper.reply(user_prompt)
answer = (result.get("result") or "").strip()
self.logger.info(f"[{self.name}] context answer: {answer}")
self.context["context_answer"] = answer
self.context.response.success = True
self.context.response.answer = answer
self.context.response.metadata.update(
{
"query": query,
"session_context": session_context,
"current_date": current_date,
"context_answer": answer,
},
)
return self.context.response

View file

@ -1,9 +0,0 @@
user_message: |
I will give you several history chats between you and a user. Please answer the question
based on the relevant chat history. Answer the question step by step: first extract all the
relevant information, and then reason over the information to get the answer.
History Chats: {session_context}
Current Date: {current_date}
Question: {query}
Answer (step by step):

View file

@ -1,103 +0,0 @@
"""extract_session_by_id — deep-read one raw session, keyed by its session_id.
This is the hand-written function tool the answering agent sees. Search results
surface a note's ``session_id``; when a hit looks relevant, the agent passes that
``session_id`` here. The step resolves the question/time from ``query.json``,
locates the raw session file (named ``<date>_(...)_<time>@<session_id>.json``
under ``resource_dir``), loads its messages, and asks an agent to extract —
completely and verbatim — every part of that session relevant to the question.
"""
import json
from pathlib import Path
from ...base_step import BaseStep
from ....components import R
@R.register("lme_extract_session_step")
class LmeExtractSessionStep(BaseStep):
"""Resolve a session_id to raw content, then deep-read it for the question."""
def _resource_dir_name(self) -> str:
return self.app_context.app_config.resource_dir if self.app_context is not None else "session"
def _load_query(self) -> dict:
path = self.workspace_path / "query.json"
with path.open(encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("query.json is not a JSON object")
return data
def _find_session_file(self, session_id: str) -> Path | None:
session_dir = self.workspace_path / self._resource_dir_name()
if not session_dir.is_dir():
return None
# Files are named "<date>_(...)_<time>@<session_id>.json".
matches = list(session_dir.glob(f"*@{session_id}.json"))
if matches:
return matches[0]
# Fall back to a plain "<session_id>.json" naming.
direct = session_dir / f"{session_id}.json"
return direct if direct.is_file() else None
async def execute(self):
assert self.context is not None
if self.agent_wrapper is None:
raise ValueError("lme_extract_session_step requires agent_wrapper")
session_id: str = str(self.context.get("session_id", "") or "").strip()
if not session_id:
self.context.response.success = False
self.context.response.answer = "Error: session_id is required"
return self.context.response
try:
query = self._load_query()
except (OSError, ValueError) as exc:
self.context.response.success = False
self.context.response.answer = f"Error: cannot read query.json: {exc}"
return self.context.response
question = str(query.get("question", "") or "").strip()
question_time = str(query.get("question_date", "") or "").strip()
session_path = self._find_session_file(session_id)
if session_path is None:
self.context.response.success = False
self.context.response.answer = (
f"Error: no session file found for session_id={session_id!r}. "
"Use a session_id shown in a search result."
)
return self.context.response
try:
with session_path.open(encoding="utf-8") as f:
session = json.load(f)
except (OSError, ValueError) as exc:
self.context.response.success = False
self.context.response.answer = f"Error: cannot read session {session_path.name}: {exc}"
return self.context.response
messages = session.get("messages") if isinstance(session, dict) else None
session_content = json.dumps(messages or session, ensure_ascii=False, indent=2)
user_prompt = self.prompt_format(
"user_message",
question=question or "(unknown)",
question_time=question_time or "(unknown)",
session_content=session_content,
)
result = await self.agent_wrapper.reply(
user_prompt,
system_prompt=self.get_prompt("system_prompt"),
)
answer = (result.get("result") or "").strip()
self.logger.info(f"[{self.name}] extracted {len(answer)} chars for session_id={session_id!r}")
self.context.response.success = True
self.context.response.answer = answer
self.context.response.metadata.update(
{"session_id": session_id, "session_file": session_path.name},
)
return self.context.response

View file

@ -1,27 +0,0 @@
system_prompt: |
You are a precise evidence extractor for the LongMemEval benchmark. You are given ONE full
chat session (raw messages) together with a target question and the time the question was asked.
Your job is to extract, COMPLETELY and VERBATIM, every part of this session that is relevant to
answering the question.
Rules:
- Extract the actual content from the session — quote the relevant user/assistant statements as
they appear. Do not summarize away details, and do not invent anything not in the session.
- Preserve all time information exactly: absolute dates, weekdays, and relative expressions
("last week", "since January 15th", "every day", durations, frequencies). Keep each fact
together with its time expression, and relate it to the question time when that matters
(e.g. a fact stated before the question date is valid evidence; note any date conflicts).
- Keep entities, names, and numbers verbatim.
- If, after reading the whole session, nothing in it is relevant to the question, reply with
exactly: NOT RELEVANT
user_message: |
Question: {question}
Question asked at: {question_time}
--- Full session content ---
{session_content}
--- End of session ---
Extract everything in this session that is relevant to answering the question, verbatim and with
all time information preserved. If nothing is relevant, reply exactly: NOT RELEVANT

View file

@ -1,280 +0,0 @@
"""Produce a final, evidence-backed answer for a LongMemEval case.
The step puts the complete query, golden-answer object, and any available
disputed reference answers directly into the prompt. Raw session content stays out of the model
context: Claude Code starts in the sample's ``session`` directory and uses its
normal file tools to inspect whichever sessions it needs. Session timestamps
are scanned only to identify evidence that did not exist at question time;
``answer_session_ids`` are not evaluated.
Claude Code is intentionally used without an output schema. Its ordinary text
reply may contain narration but must include exactly one fenced ``json`` block
whose object contains ``reason``, ``golden_answer_correct``, ``answer``, and
``is_session_time_wrong``. API errors and invalid replies are retried with
capped exponential backoff.
"""
import asyncio
import json
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Any
from ....components import R
from ...base_step import BaseStep
DEFAULT_REFERENCE_PATHS = (
"benchmark/longmemeval/golden_check_list_false.jsonl",
"benchmark/longmemeval/merge_confirm_jinli_false.jsonl",
)
REFERENCE_PATHS_ENV = "LME_FINAL_ANSWER_REFERENCE_PATHS"
RETRY_INITIAL_SECONDS = 5.0
RETRY_MAX_SECONDS = 300.0
_LME_DATETIME_RE = re.compile(r"(\d{4})/(\d{2})/(\d{2}).*?(\d{2}):(\d{2})")
_FENCED_JSON_RE = re.compile(r"```json\s*(.*?)\s*```", re.IGNORECASE | re.DOTALL)
@R.register("lme_final_answer_review_step")
class FinalAnswerReviewStep(BaseStep):
"""Ask a Claude Code agent to review one golden answer."""
@staticmethod
def _load_json(path: Path) -> dict[str, Any]:
try:
with path.open(encoding="utf-8") as file:
value = json.load(file)
except OSError as exc:
raise FileNotFoundError(f"Cannot read LongMemEval file: {path}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in LongMemEval file: {path}") from exc
if not isinstance(value, dict):
raise ValueError(f"Expected a JSON object in {path}")
return value
@staticmethod
def _parse_datetime(raw_date: Any, *, source: str) -> datetime:
text = str(raw_date or "").strip()
match = _LME_DATETIME_RE.search(text)
if match is None:
raise ValueError(f"Invalid LongMemEval datetime in {source}: {text!r}")
try:
return datetime(*(int(part) for part in match.groups()))
except ValueError as exc:
raise ValueError(
f"Invalid LongMemEval datetime in {source}: {text!r}",
) from exc
def _resolve_reference_path(self, raw_path: str) -> Path:
path = Path(raw_path).expanduser()
if path.is_absolute():
return path
# The configured defaults are repository-relative. Tests and custom
# jobs may instead provide workspace-relative fixture paths.
repository_path = Path.cwd() / path
if repository_path.is_file():
return repository_path
return self.workspace_path / path
def _load_references(self, question_id: str) -> list[dict[str, Any]]:
raw_paths: Any
serialized_paths = os.environ.get(REFERENCE_PATHS_ENV)
if serialized_paths:
try:
raw_paths = json.loads(serialized_paths)
except json.JSONDecodeError as exc:
raise ValueError(f"{REFERENCE_PATHS_ENV} must be a JSON array of paths") from exc
else:
raw_paths = self.kwargs.get("reference_paths") or DEFAULT_REFERENCE_PATHS
if isinstance(raw_paths, str):
raw_paths = [raw_paths]
if not isinstance(raw_paths, (list, tuple)) or not raw_paths:
raise ValueError("reference_paths must contain at least one JSONL path")
references: list[dict[str, Any]] = []
for raw_path in raw_paths:
path = self._resolve_reference_path(str(raw_path))
try:
with path.open(encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
if not line.strip():
continue
try:
item = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(
f"Invalid JSONL at {path}:{line_number}",
) from exc
if not isinstance(item, dict):
raise ValueError(
f"Expected a JSON object at {path}:{line_number}",
)
if str(item.get("question_id") or "") == question_id:
references.append({"source": path.name, **item})
except OSError as exc:
raise FileNotFoundError(
f"Cannot read reference-answer file: {path}",
) from exc
return references
def _inspect_session_times(self, question_dt: datetime) -> tuple[int, list[dict[str, str]]]:
"""Return the session count and timestamp-only metadata for future sessions."""
resource_dir = self.app_context.app_config.resource_dir if self.app_context is not None else "session"
session_dir = self.workspace_path / resource_dir
if not session_dir.is_dir():
raise FileNotFoundError(f"Session directory not found: {session_dir}")
session_paths = sorted(session_dir.glob("*.json"))
future_sessions: list[dict[str, str]] = []
for path in session_paths:
session = self._load_json(path)
session_id = str(session.get("haystack_session_id") or path.stem)
session_date = str(session.get("haystack_date") or "").strip()
session_dt = self._parse_datetime(
session_date,
source=f"{path}:haystack_date",
)
if session_dt > question_dt:
future_sessions.append(
{
"session_id": session_id,
"session_date": session_date,
"session_file": path.name,
},
)
return len(session_paths), future_sessions
@staticmethod
def _parse_reply(raw_reply: Any) -> dict[str, Any]:
if not isinstance(raw_reply, str) or not raw_reply.strip():
raise ValueError("Agent returned an empty reply")
json_blocks = _FENCED_JSON_RE.findall(raw_reply)
if len(json_blocks) != 1:
raise ValueError("Agent reply must contain exactly one fenced ```json``` block")
try:
value = json.loads(json_blocks[0].strip())
except json.JSONDecodeError as exc:
raise ValueError("Agent's fenced json block is not valid JSON") from exc
if not isinstance(value, dict):
raise ValueError("Agent reply must be a JSON object")
if set(value) != {"reason", "golden_answer_correct", "answer", "is_session_time_wrong"}:
raise ValueError(
"Agent reply must contain exactly 'reason', 'golden_answer_correct', 'answer', "
"and 'is_session_time_wrong'",
)
answer = value["answer"]
reason = value["reason"]
golden_answer_correct = value["golden_answer_correct"]
is_session_time_wrong = value["is_session_time_wrong"]
if not isinstance(reason, str) or not reason.strip():
raise ValueError("Agent reply 'reason' must be a non-empty string")
if "answer_session_ids" in reason.casefold():
raise ValueError("Agent reply 'reason' must not evaluate answer_session_ids")
if not isinstance(golden_answer_correct, bool):
raise ValueError("Agent reply 'golden_answer_correct' must be a boolean")
if not isinstance(answer, str):
raise ValueError("Agent reply 'answer' must be a string")
answer = answer.strip()
if golden_answer_correct and answer:
raise ValueError("Agent reply 'answer' must be empty when golden_answer_correct is true")
if not golden_answer_correct and not answer:
raise ValueError("Agent reply 'answer' must be non-empty when golden_answer_correct is false")
if not isinstance(is_session_time_wrong, bool):
raise ValueError("Agent reply 'is_session_time_wrong' must be a boolean")
if is_session_time_wrong:
raise ValueError("Agent reply 'is_session_time_wrong' is deprecated and must be false")
return {
"reason": reason.strip(),
"golden_answer_correct": golden_answer_correct,
"answer": answer,
"is_session_time_wrong": is_session_time_wrong,
}
async def execute(self):
assert self.context is not None
if self.agent_wrapper is None:
raise ValueError("lme_final_answer_review_step requires agent_wrapper")
query = self._load_json(self.workspace_path / "query.json")
golden = self._load_json(self.workspace_path / "answer.json")
question_id = str(query.get("question_id") or "").strip()
if not question_id:
raise ValueError("query.json requires a non-empty 'question_id'")
question_dt = self._parse_datetime(
query.get("question_date"),
source="query.json:question_date",
)
references = self._load_references(question_id)
num_sessions, future_sessions = self._inspect_session_times(question_dt)
payload = {
"query": query,
"answer_json": golden,
"reference_answers": references,
"session_time_check": {
"sessions_after_question_date": future_sessions,
},
}
user_prompt = self.prompt_format(
"user_message",
question_id=question_id,
question_date=str(query.get("question_date") or ""),
num_sessions=num_sessions,
num_future_sessions=len(future_sessions),
num_references=len(references),
payload_json=json.dumps(payload, ensure_ascii=False, indent=2),
)
retry_initial_seconds = float(
self.kwargs.get("retry_initial_seconds", RETRY_INITIAL_SECONDS),
)
retry_max_seconds = float(
self.kwargs.get("retry_max_seconds", RETRY_MAX_SECONDS),
)
if retry_initial_seconds <= 0:
retry_initial_seconds = RETRY_INITIAL_SECONDS
retry_max_seconds = max(retry_max_seconds, retry_initial_seconds)
attempt = 1
sleep_seconds = retry_initial_seconds
while True:
try:
# Deliberately do not pass output_schema: this case evaluates an
# ordinary Claude Code response and validates it afterward.
result = await self.agent_wrapper.reply(
user_prompt,
system_prompt=self.get_prompt("system_prompt"),
)
final_answer = self._parse_reply(result.get("result"))
if attempt > 1:
self.logger.info(
f"[{self.name}] recovered after {attempt} attempts",
)
break
except Exception as exc: # noqa: BLE001 - agent/API/format failures share the retry contract
delay = min(sleep_seconds, retry_max_seconds)
self.logger.warning(
f"[{self.name}] attempt {attempt} failed for {question_id}: {exc}; retrying in {delay:.1f}s",
)
await asyncio.sleep(delay)
sleep_seconds = min(sleep_seconds * 2, retry_max_seconds)
attempt += 1
self.context.response.success = True
self.context.response.answer = json.dumps(final_answer, ensure_ascii=False)
self.context.response.metadata.update(
{
"question_id": question_id,
"num_sessions": num_sessions,
"num_future_sessions": len(future_sessions),
"future_sessions": future_sessions,
"num_reference_answers": len(references),
"is_session_time_wrong": False,
"attempts": attempt,
"agent_session_id": result.get("session_id"),
},
)
return self.context.response

View file

@ -1,53 +0,0 @@
system_prompt: |
你是 LongMemEval 答案的最终审核员。完整的 query.json、answer.json,以及零个或多个可能正确、
也可能错误的参考答案已经放在用户消息的 input JSON 中,不需要去其他目录寻找这些输入。没有参考
答案时,应直接根据原始 session 独立审核 answer.json,不能因为缺少争议记录就假定 golden 答案正确。
你的当前工作目录就是该问题的 session 目录。目录中的每个 JSON 文件都是一个完整原始聊天 session。
原始 session 内容没有预先放进上下文;请主动使用 Read、Glob、Grep、Bash 等工具在当前目录自由检索,
并阅读所有与问题可能相关的 session。不要修改或删除这些文件。
你的任务是独立判断最合理的答案。answer.json 和 reference_answers 都只是待核对的线索,不是事实,
不能因为多个参考答案一致就直接采纳。必须综合全部聊天记录,仔细区分用户与 assistant 的陈述,处理
时间、更新、冲突、计数、偏好和指代关系。
检索时必须始终检查每个文件中的 haystack_date:发生在 question_date 之后的 session 属于未来
信息,绝对不能用其聊天内容推导正确答案或判断 answer.json 正确。即使未来 session 给出了非常直接、
看似正确或与参考答案一致的信息,也必须忽略其内容,避免时间穿越。必须先仅根据 question_date 当时
已经存在的 session 独立得出正确答案,再与 answer.json 比较;合法证据不足时,正确答案为 unknown。
`answer_session_ids` 不属于本次审核对象。不要检查其是否完整、相关、存在或晚于 question_date,也
不得因其包含未来、无关或错误的 session ID 而把 golden answer 判错。`golden_answer_correct` 只由
`answer.json` 中 `answer` 的内容是否完整、正确决定。
input JSON 中的 session_time_check 只用于指出哪些 session 内容晚于 question_date、不能作为答题
证据;它不用于检查 `answer_session_ids`。reason 中不需要评价 `answer_session_ids`。
你可以在最终回复中补充必要的分析文字,但必须包含且只能包含一个 ```json 代码块。程序只解析这个
代码块;没有代码块、存在多个 json 代码块或块内 JSON 无效都会触发重试。代码块内必须是一个对象,
且只能包含四个字段:
- reason:中文详细推理。说明如何处理不同线索和参考答案,尽量逐条引用有证据作用的 session id、
session 时间与具体事实,使后续人工 reviewer 可以复核。
- golden_answer_correct:JSON boolean。仅根据 question_date 之前(含同一时刻)的 session 判断
answer.json 中的 answer 是否完整且正确;不要考虑 answer_session_ids。
- answer:仅当 golden_answer_correct 为 false 时,填写合法证据支持的正确答案(证据不足填
unknown);为 true 时必须填空字符串。
- is_session_time_wrong:为兼容现有输出结构保留的弃用字段,始终填 false。
不要在 reason 或任何字段中评价 answer_session_ids。
输出格式示例仅用于说明 JSON 外形,不是内容 few-shot:
```json
{"reason":"详细推理与 session 证据","golden_answer_correct":false,"answer":"修正答案","is_session_time_wrong":false}
```
user_message: |
请审核 question_id={question_id}。
Question date: {question_date}
Session files in current working directory: {num_sessions}
Sessions after question_date: {num_future_sessions}
Reference answer count: {num_references}
以下 input JSON 包含完整 query.json、answer.json、参考答案和 session 时间检查结果。请先读完,再使用当前
session 目录中的原始文件查找证据,独立推理后严格按 system prompt 要求输出带 ```json 代码块的结果:
{payload_json}

View file

@ -1,188 +0,0 @@
"""Judge whether the LongMemEval golden answer is reasonable.
Consumes ``session_review.json`` produced by ``lme_session_review_step`` and
hands its extracted session information to an agent that is equipped with the
``python_execute`` tool. The agent uses ``python_execute`` only as a scratchpad
for checking the golden answer; ``answer_session_ids`` are outside the audit
scope. The final verdict is not the
raw Python stdout but a *structured* object extracted from the whole conversation
via ``output_schema``. Sessions dated after ``question_date`` are filtered
upstream by ``lme_session_review_step`` and are not included in this
golden-check flow.
The output ``check_golden.json`` is intentionally slim: it does NOT duplicate the
query/golden/review fields already stored in ``session_review.json`` (referenced by
path), keeping only the relevant per-session ``session_summaries`` and the
structured verdict. It is written to the workspace root (e.g.
``datasets/longmemeval/1/check_golden.json``).
"""
import json
import asyncio
from uuid import uuid4
from ...base_step import BaseStep
from ....components import R
# File written under the workspace root with the full review + verdict payload.
OUTPUT_FILENAME = "check_golden.json"
SESSION_REVIEW_FILENAME = "session_review.json"
RETRY_INITIAL_SECONDS = 5.0
RETRY_MAX_SECONDS = 300.0
# Structured verdict the judge agent must produce (extracted from its reasoning).
_VERDICT_SCHEMA = {
"type": "object",
"properties": {
"reasoning": {
"type": "string",
"description": "用中文写出详细的推理过程:先说明证据支持的答案,再判断 " "golden_answer 是否正确。",
},
"golden_answer_correct": {
"type": "boolean",
"description": "golden_answer 是否正确。",
},
"true_answer": {
"type": "string",
"description": "仅当 golden_answer_correct 为 false 时填写:证据支持的正确答案"
"(证据不足时填 'unknown')。golden_answer_correct 为 true 时填空字符串。",
},
},
"required": [
"reasoning",
"golden_answer_correct",
"true_answer",
],
"additionalProperties": False,
}
@R.register("lme_golden_check_step")
class GoldenCheckStep(BaseStep):
"""Let a python-enabled agent decide whether the golden answer holds up."""
@staticmethod
def _compact_summary(summary: dict) -> dict:
"""Keep only the evidence fields used by the golden-check prompt."""
return {
"session_id": str(summary.get("session_id") or ""),
"session_date": str(summary.get("session_date") or ""),
"extracted_info": str(summary.get("extracted_info") or ""),
}
async def execute(self):
assert self.context is not None
if self.agent_wrapper is None:
raise ValueError("lme_golden_check_step requires agent_wrapper")
review_path = self.workspace_path / SESSION_REVIEW_FILENAME
if not review_path.is_file():
raise FileNotFoundError(
f"{SESSION_REVIEW_FILENAME} not found at {review_path}; run lme_session_review_step first",
)
try:
with review_path.open(encoding="utf-8") as f:
review_payload = json.load(f)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in {review_path}") from exc
if not isinstance(review_payload, dict):
raise ValueError(f"Expected a JSON object in {review_path}")
query = review_payload.get("query") or {}
golden = review_payload.get("golden") or {}
# session_review.json keeps one extraction per reviewed session.
session_summaries = [self._compact_summary(s) for s in review_payload.get("session_summaries") or []]
question = str(query.get("question") or "").strip()
question_type = str(query.get("question_type") or "").strip()
question_date = str(query.get("question_date") or "").strip()
golden_answer = str(golden.get("answer") or "").strip()
if not question:
raise ValueError(f"{review_path} does not contain a question")
prompt_input = {
"question": question,
"question_type": question_type,
"question_date": question_date,
"golden_answer": golden_answer,
"session_summaries": session_summaries,
}
user_prompt = self.prompt_format(
"user_message",
question=question,
question_type=question_type,
question_date=question_date,
golden_answer=golden_answer,
num_session_summaries=len(session_summaries),
payload_json=json.dumps(prompt_input, ensure_ascii=False, indent=2),
)
tool_context_id = str(self.context.get("tool_context_id") or f"lme-golden-{uuid4()}")
retry_initial_seconds = float(self.kwargs.get("retry_initial_seconds", RETRY_INITIAL_SECONDS))
retry_max_seconds = float(self.kwargs.get("retry_max_seconds", RETRY_MAX_SECONDS))
retry_max_attempts_raw = self.kwargs.get("retry_max_attempts")
retry_max_attempts = int(retry_max_attempts_raw) if retry_max_attempts_raw not in (None, "") else 0
if retry_initial_seconds <= 0:
retry_initial_seconds = RETRY_INITIAL_SECONDS
retry_max_seconds = max(retry_max_seconds, retry_initial_seconds)
attempt = 1
sleep_seconds = retry_initial_seconds
while True:
try:
result = await self.agent_wrapper.reply(
user_prompt,
system_prompt=self.get_prompt("system_prompt"),
tool_context_id=tool_context_id,
output_schema=_VERDICT_SCHEMA,
)
if attempt > 1:
self.logger.info(f"[{self.name}] golden check recovered after {attempt} attempts")
break
except Exception as exc:
if 0 < retry_max_attempts <= attempt:
raise
next_sleep = min(sleep_seconds, retry_max_seconds)
self.logger.warning(
f"[{self.name}] golden check attempt {attempt} failed: {exc}; " f"retrying in {next_sleep:.1f}s",
)
await asyncio.sleep(next_sleep)
sleep_seconds = min(sleep_seconds * 2, retry_max_seconds)
attempt += 1
# The structured verdict is the real output; the free-text reply is only the
# agent's closing narration and is kept as a fallback.
verdict = result.get("structured_output")
if not isinstance(verdict, dict):
self.logger.warning(f"[{self.name}] no structured verdict; falling back to free text")
verdict = {"reasoning": (result.get("result") or "").strip()}
# Retain the legacy fields for readers of existing check_golden.json
# artifacts. They are compatibility placeholders, not audit results.
verdict["answer_session_ids_correct"] = True
verdict["true_answer_session_ids"] = []
# Slim output: do NOT duplicate session_review.json (referenced by path);
# keep only the compact session_summaries and the verdict.
output = {
"session_review_path": str(review_path),
"session_summaries": session_summaries,
"verdict": verdict,
}
output_path = self.workspace_path / OUTPUT_FILENAME
with output_path.open("w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
self.logger.info(f"[{self.name}] wrote verdict to {output_path}")
self.context.response.success = True
self.context.response.answer = json.dumps(verdict, ensure_ascii=False, indent=2)
self.context.response.metadata.update(
{
"num_session_summaries": len(session_summaries),
"session_review_path": str(review_path),
"tool_context_id": tool_context_id,
"agent_session_id": result.get("session_id"),
"output_path": str(output_path),
"verdict": verdict,
},
)
return self.context.response

View file

@ -1,27 +0,0 @@
system_prompt: |
你是 LongMemEval 基准测试的审核员。你要根据从用户聊天记录中提取的证据,判断某个问题的
golden_answer 是否正确。只审核答案内容,不检查或评价 answer_session_ids。
你可以使用 python_execute 工具作为推理草稿本:统计相关会话、抽取答案。把给定的数据以字面量
形式直接嵌入 Python 代码,使计算可复现;把中间结果以 JSON 打印出来,便于审计。
python 的 stdout 不是你的最终答案,只是草稿。计算充分、确信之后,停止调用 python,用中文
给出结论。最终的结构化结果会从整段对话中自动抽取,所以务必把推理和结论清楚表达。
结构化输出要求:
- reasoning 必须是详细的中文推理过程。
- true_answer 仅在 golden_answer_correct 为 false 时填写,为空字符串否则。
user_message: |
Question: {question}
Question type: {question_type}
Question date: {question_date}
Golden answer: {golden_answer}
Number of session extractions included: {num_session_summaries}
证据(JSON)。session_summaries 含上游审核过的会话,每条只有 session_id、session_date、
extracted_info:
{payload_json}
用 python_execute 统计和推理,过程中打印中间 JSON。然后用中文给出最终结论:golden_answer
是否正确(不正确时给出 true_answer)。不要评价 answer_session_ids。

View file

@ -0,0 +1,81 @@
"""Judge whether an agent answer matches the golden answer."""
import re
from ...base_step import BaseStep
from ....components import R
@R.register("lme_answer_judge_step")
class LmeAnswerJudgeStep(BaseStep):
"""Evaluate whether an agent answer is correct against a golden answer."""
PROMPT_KEYS_BY_QUESTION_TYPE = {
"temporal_reasoning": "temporal_reasoning_system_prompt",
"knowledge_update": "knowledge_update_system_prompt",
"single_session_preference": "single_session_preference_system_prompt",
}
@classmethod
def _judge_prompt_key(cls, question_type: str) -> str:
normalized = question_type.strip().lower().replace("-", "_").replace(" ", "_")
return cls.PROMPT_KEYS_BY_QUESTION_TYPE.get(normalized, "other_question_types_system_prompt")
@staticmethod
def _normalize_judgement(raw_answer: str) -> str:
match = re.match(r"\s*(yes|no)\b", raw_answer, re.IGNORECASE)
if match:
return match.group(1).lower()
return raw_answer.strip().lower()
async def execute(self):
assert self.context is not None
query: str = self.context.get("query", "")
agent_answer: str = self.context.get("agent_answer", "")
golden_answer: str = self.context.get("golden_answer", "")
question_type: str = self.context.get("question_type", "")
if not query:
raise ValueError("lme_answer_judge_step requires non-empty query")
if not agent_answer:
raise ValueError("lme_answer_judge_step requires non-empty agent_answer")
if not golden_answer:
raise ValueError("lme_answer_judge_step requires non-empty golden_answer")
if self.agent_wrapper is None:
raise RuntimeError("lme_answer_judge_step requires agent_wrapper")
judge_prompt_key = self._judge_prompt_key(question_type)
user_prompt_key = (
"preference_judge_user_message"
if judge_prompt_key == "single_session_preference_system_prompt"
else "answer_judge_user_message"
)
user_prompt = self.prompt_format(
user_prompt_key,
query=query,
golden_answer=golden_answer,
agent_answer=agent_answer,
)
result = await self.agent_wrapper.reply(
user_prompt,
system_prompt=self.prompt_format(judge_prompt_key),
)
raw_answer = (result.get("result") or "").strip()
answer = self._normalize_judgement(raw_answer)
self.logger.info(f"[{self.name}] answer judgement: {answer}")
self.context["answer_judgement"] = answer
self.context.response.success = True
self.context.response.answer = answer
self.context.response.metadata.update(
{
"query": query,
"agent_answer": agent_answer,
"golden_answer": golden_answer,
"question_type": question_type,
"answer_judgement": answer,
"raw_answer_judgement": raw_answer,
},
)
return self.context.response

View file

@ -1,109 +0,0 @@
"""LME file-based LLM judge step."""
import json
import re
from ...base_step import BaseStep
from ....components import R
@R.register("lme_llm_judge_step")
class LmeLlmJudgeStep(BaseStep):
"""Judge ``mem_answer.json`` against ``answer.json`` and update it in place."""
PROMPT_KEYS_BY_QUESTION_TYPE = {
"temporal_reasoning": "temporal_reasoning_system_prompt",
"knowledge_update": "knowledge_update_system_prompt",
"single_session_preference": "single_session_preference_system_prompt",
}
@classmethod
def _judge_prompt_key(cls, question_type: str) -> str:
normalized = question_type.strip().lower().replace("-", "_").replace(" ", "_")
return cls.PROMPT_KEYS_BY_QUESTION_TYPE.get(normalized, "other_question_types_system_prompt")
@staticmethod
def _user_prompt_key(judge_prompt_key: str) -> str:
if judge_prompt_key == "single_session_preference_system_prompt":
return "preference_judge_user_message"
return "answer_judge_user_message"
@staticmethod
def _normalize_judgement(raw_answer: str) -> str:
match = re.match(r"\s*(yes|no)\b", raw_answer, re.IGNORECASE)
if match:
return match.group(1).lower()
return raw_answer.strip().lower()
def _load_json_object(self, filename: str) -> dict:
path = self.workspace_path / filename
if not path.exists():
raise FileNotFoundError(f"{filename} does not exist in {self.workspace_path}")
with path.open(encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(f"{filename} is not a JSON object")
return data
async def execute(self):
assert self.context is not None
if self.agent_wrapper is None:
raise RuntimeError("lme_llm_judge_step requires agent_wrapper")
query_data = self._load_json_object("query.json")
golden_data = self._load_json_object("answer.json")
mem_answer = self._load_json_object("mem_answer.json")
query = str(query_data.get("question", "") or "").strip()
agent_answer = str(mem_answer.get("answer", "") or "").strip()
golden_answer = str(golden_data.get("answer", "") or "").strip()
question_type = str(query_data.get("question_type", "") or "")
if not query:
raise ValueError("query.json requires a non-empty 'question'")
if not agent_answer:
raise ValueError("mem_answer.json requires a non-empty 'answer'")
if not golden_answer:
raise ValueError("answer.json requires a non-empty 'answer'")
judge_prompt_key = self._judge_prompt_key(question_type)
user_prompt = self.prompt_format(
self._user_prompt_key(judge_prompt_key),
query=query,
golden_answer=golden_answer,
agent_answer=agent_answer,
)
result = await self.agent_wrapper.reply(
user_prompt,
system_prompt=self.prompt_format(judge_prompt_key),
)
raw_answer = (result.get("result") or "").strip()
answer = self._normalize_judgement(raw_answer)
mem_answer["llm_judge"] = {
"judgement": answer,
"raw_judgement": raw_answer,
"golden_answer": golden_answer,
"question_type": question_type,
}
out_path = self.workspace_path / "mem_answer.json"
out_path.write_text(json.dumps(mem_answer, ensure_ascii=False, indent=2), encoding="utf-8")
question_id = str(query_data.get("question_id") or mem_answer.get("question_id") or "")
self.logger.info(f"[{self.name}] llm judgement for {question_id or query!r}: {answer}")
self.context.response.success = True
self.context.response.answer = answer
self.context.response.metadata.update(
{
"question_id": question_id,
"query": query,
"agent_answer": agent_answer,
"golden_answer": golden_answer,
"question_type": question_type,
"answer_judgement": answer,
"raw_answer_judgement": raw_answer,
"path": "mem_answer.json",
},
)
return self.context.response

View file

@ -1,358 +0,0 @@
"""Review every LongMemEval session and extract its information.
For a workspace such as ``datasets/longmemeval/1`` this step loads ``query.json``
and ``answer.json``, filters out sessions dated after ``question_date``, then
walks each remaining session under ``resource_dir`` one by one. An agent wrapper
extracts the complete information in each session, with extra care not to omit
anything related to the question or golden answer.
The collected per-session extractions are written to ``session_review.json`` for
the downstream golden-answer check.
"""
import asyncio
import json
import re
import time
from datetime import datetime
from pathlib import Path
from ...base_step import BaseStep
from ....components import R
START_INTERVAL_SECONDS = 1.0
MAX_CONCURRENCY = 60
RETRY_INITIAL_SECONDS = 5.0
RETRY_MAX_SECONDS = 300.0
OUTPUT_FILENAME = "session_review.json"
_LME_DATETIME_RE = re.compile(r"(\d{4})/(\d{2})/(\d{2}).*?(\d{2}):(\d{2})")
_NON_RETRYABLE_DATA_INSPECTION_MARKERS = (
"data_inspection_failed",
"DataInspectionFailed",
"Input text data may contain inappropriate content",
)
@R.register("lme_session_review_step")
class SessionReviewStep(BaseStep):
"""Extract complete information from every eligible session."""
def _load_json(self, path: Path | str) -> dict:
if not isinstance(path, Path):
path = self.workspace_path / path
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
except OSError as exc:
raise FileNotFoundError(f"Cannot read LongMemEval file: {path}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in LongMemEval file: {path}") from exc
if not isinstance(data, dict):
raise ValueError(f"Expected a JSON object in {path}")
return data
def _session_dir(self) -> Path:
resource_dir = self.app_context.app_config.resource_dir if self.app_context is not None else "session"
return self.workspace_path / resource_dir
@staticmethod
def _parse_lme_datetime(raw_date: str) -> datetime | None:
"""Parse LongMemEval timestamps like ``2023/05/20 (Sat) 03:29``."""
match = _LME_DATETIME_RE.search(raw_date.strip())
if match is None:
return None
try:
year, month, day, hour, minute = (int(part) for part in match.groups())
return datetime(year, month, day, hour, minute)
except ValueError:
return None
@staticmethod
def _is_data_inspection_error(exc: Exception) -> bool:
text = str(exc)
return any(marker in text for marker in _NON_RETRYABLE_DATA_INSPECTION_MARKERS)
# pylint: disable=too-many-statements
async def execute(self):
assert self.context is not None
if self.agent_wrapper is None:
raise ValueError("lme_session_review_step requires agent_wrapper")
query_data = self._load_json("query.json")
answer_data = self._load_json("answer.json")
question = str(query_data.get("question") or "").strip()
question_type = str(query_data.get("question_type") or "").strip()
question_date = str(query_data.get("question_date") or "").strip()
if not question:
raise ValueError("query.json requires a non-empty 'question'")
question_dt = self._parse_lme_datetime(question_date)
if question_dt is None:
raise ValueError(f"query.json has an invalid 'question_date': {question_date!r}")
golden_answer = str(answer_data.get("answer") or "").strip()
answer_session_ids = [str(s) for s in (answer_data.get("answer_session_ids") or [])]
session_dir = self._session_dir()
if not session_dir.is_dir():
raise FileNotFoundError(f"Session directory not found: {session_dir}")
session_files = sorted(p for p in session_dir.iterdir() if p.suffix == ".json")
sessions: list[tuple[dict, str, str]] = []
filtered_sessions: list[dict] = []
session_ids_illegal: list[str] = []
answer_session_ids_illegal: list[str] = []
answer_session_id_set = set(answer_session_ids)
for session_path in session_files:
try:
session = self._load_json(session_path)
except (ValueError, FileNotFoundError) as exc:
self.logger.warning(f"[{self.name}] skip {session_path.name}: {exc}")
continue
session_id = str(session.get("haystack_session_id") or session_path.stem)
session_date = str(session.get("haystack_date") or "").strip()
session_dt = self._parse_lme_datetime(session_date)
if session_dt is not None and session_dt > question_dt:
session_ids_illegal.append(session_id)
filtered_sessions.append(
{
"session_id": session_id,
"session_date": session_date,
"session_file": session_path.name,
"reason": "session_date_after_question_date",
},
)
if session_id in answer_session_id_set:
answer_session_ids_illegal.append(session_id)
continue
if session_dt is None:
self.logger.warning(
f"[{self.name}] keep {session_id}: cannot parse haystack_date={session_date!r}",
)
sessions.append((session, session_id, session_date))
illegal_answer_session_ids = set(answer_session_ids_illegal)
answer_session_ids_filter_illegal = [
session_id for session_id in answer_session_ids if session_id not in illegal_answer_session_ids
]
total = len(sessions)
start_interval_seconds = float(self.kwargs.get("start_interval_seconds", START_INTERVAL_SECONDS))
if start_interval_seconds < 0:
start_interval_seconds = START_INTERVAL_SECONDS
concurrency = int(self.kwargs.get("concurrency", MAX_CONCURRENCY))
if concurrency <= 0:
concurrency = MAX_CONCURRENCY
concurrency = min(concurrency, MAX_CONCURRENCY)
self.logger.info(
f"[{self.name}] reviewing {total} sessions from {session_dir} "
f"(filtered {len(session_ids_illegal)} sessions after question_date, "
f"start_interval={start_interval_seconds}s, concurrency={concurrency})",
)
failed_reviews: list[dict] = []
retry_initial_seconds = float(self.kwargs.get("retry_initial_seconds", RETRY_INITIAL_SECONDS))
retry_max_seconds = float(self.kwargs.get("retry_max_seconds", RETRY_MAX_SECONDS))
retry_max_attempts_raw = self.kwargs.get("retry_max_attempts")
retry_max_attempts = int(retry_max_attempts_raw) if retry_max_attempts_raw not in (None, "") else 0
if retry_initial_seconds <= 0:
retry_initial_seconds = RETRY_INITIAL_SECONDS
retry_max_seconds = max(retry_max_seconds, retry_initial_seconds)
retry_gate = asyncio.Condition()
retry_sleeping_review_idxs: set[int] = set()
submit_lock = asyncio.Lock()
last_submitted_at = 0.0
def has_prior_retry_sleeping(idx: int) -> bool:
return any(retry_idx < idx for retry_idx in retry_sleeping_review_idxs)
async def wait_for_start_slot() -> None:
nonlocal last_submitted_at
async with submit_lock:
sleep_seconds = last_submitted_at + start_interval_seconds - time.monotonic()
if sleep_seconds > 0:
await asyncio.sleep(sleep_seconds)
last_submitted_at = time.monotonic()
async def wait_for_healthy_start_slot(idx: int, session_id: str) -> None:
while True:
async with retry_gate:
if has_prior_retry_sleeping(idx):
self.logger.info(
f"[{self.name}] ({idx}/{total}) {session_id} waits for earlier retry sleep",
)
await retry_gate.wait_for(lambda: not has_prior_retry_sleeping(idx))
await wait_for_start_slot()
async with retry_gate:
if not has_prior_retry_sleeping(idx):
return
async def mark_retry_sleeping(idx: int) -> None:
async with retry_gate:
retry_sleeping_review_idxs.add(idx)
retry_gate.notify_all()
async def mark_retry_awake(idx: int) -> None:
async with retry_gate:
retry_sleeping_review_idxs.discard(idx)
retry_gate.notify_all()
async def reply_with_retry(idx: int, user_prompt: str, session_id: str) -> dict:
attempt = 1
sleep_seconds = retry_initial_seconds
while True:
try:
await wait_for_healthy_start_slot(idx, session_id)
result = await self.agent_wrapper.reply(
user_prompt,
system_prompt=self.get_prompt("system_prompt"),
)
await mark_retry_awake(idx)
if attempt > 1:
self.logger.info(f"[{self.name}] review recovered for {session_id} after {attempt} attempts")
return result
except Exception as exc:
if self._is_data_inspection_error(exc):
await mark_retry_awake(idx)
raise
if 0 < retry_max_attempts <= attempt:
await mark_retry_awake(idx)
raise
await mark_retry_sleeping(idx)
next_sleep = min(sleep_seconds, retry_max_seconds)
self.logger.warning(
f"[{self.name}] review attempt {attempt} failed for {session_id}: {exc}; "
f"retrying in {next_sleep:.1f}s",
)
await asyncio.sleep(next_sleep)
await mark_retry_awake(idx)
sleep_seconds = min(sleep_seconds * 2, retry_max_seconds)
attempt += 1
async def review_one(idx: int, session: dict, session_id: str, session_date: str) -> dict | None:
user_prompt = self.prompt_format(
"user_message",
question=question,
question_type=question_type,
question_date=question_date,
golden_answer=golden_answer,
session_id=session_id,
session_date=session_date,
session_content=json.dumps(session.get("messages", []), ensure_ascii=False, indent=2),
)
try:
result = await reply_with_retry(idx, user_prompt, session_id)
except Exception as exc: # noqa: BLE001 — one bad session must not abort the sweep
if self._is_data_inspection_error(exc):
error = str(exc)
self.logger.warning(
f"[{self.name}] review fallback for {session_id}: non-retryable data inspection error",
)
failed_reviews.append(
{
"session_id": session_id,
"session_date": session_date,
"error": error,
"non_retryable": True,
"fallback": True,
"fallback_reason": "data_inspection_failed",
"raw_session": session,
},
)
return {
"session_id": session_id,
"session_date": session_date,
"extracted_info": "",
"review_status": "fallback",
"fallback_reason": "data_inspection_failed",
"error": error,
"raw_session": session,
}
self.logger.warning(f"[{self.name}] review failed for {session_id}: {exc}")
failed_reviews.append(
{
"session_id": session_id,
"session_date": session_date,
"error": str(exc),
"non_retryable": False,
"fallback": False,
},
)
return None
extracted_info = str(result.get("result") or "").strip()
summary = {
"session_id": session_id,
"session_date": session_date,
"extracted_info": extracted_info,
}
self.logger.info(f"[{self.name}] ({idx}/{total}) extracted {session_id}")
return summary
review_semaphore = asyncio.Semaphore(concurrency)
async def review_one_limited(idx: int, session: dict, session_id: str, session_date: str) -> dict | None:
async with review_semaphore:
return await review_one(idx, session, session_id, session_date)
# gather preserves input order, so summaries stay chronological.
results = await asyncio.gather(
*(
review_one_limited(idx, session, session_id, session_date)
for idx, (session, session_id, session_date) in enumerate(sessions, start=1)
),
)
summaries: list[dict] = [s for s in results if s is not None]
non_empty_summaries = [s for s in summaries if str(s.get("extracted_info") or "").strip()]
fallback_summaries = [s for s in summaries if s.get("review_status") == "fallback"]
reviewed_session_ids = [str(s.get("session_id")) for s in summaries if s.get("session_id")]
output = {
"query": {
"question_id": query_data.get("question_id"),
"question": question,
"question_type": question_type,
"question_date": question_date,
},
"golden": {
"answer": golden_answer,
"answer_session_ids": answer_session_ids,
"answer_session_ids_filter_illegal": answer_session_ids_filter_illegal,
"answer_session_ids_illegal": answer_session_ids_illegal,
},
"review": {
"num_session_files": len(session_files),
"num_reviewed_sessions": len(summaries),
"num_extracted_sessions": len(non_empty_summaries),
"num_empty_extractions": len(summaries) - len(non_empty_summaries),
"num_failed_reviews": len(failed_reviews),
"num_fallback_reviews": len(fallback_summaries),
"num_filtered_sessions": len(session_ids_illegal),
"reviewed_session_ids": reviewed_session_ids,
"session_ids_illegal": session_ids_illegal,
"filtered_sessions": filtered_sessions,
"failed_reviews": failed_reviews,
"fallback_reviews": fallback_summaries,
},
"session_summaries": summaries,
}
output_path = self.workspace_path / OUTPUT_FILENAME
with output_path.open("w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
self.logger.info(f"[{self.name}] wrote session review to {output_path}")
self.context.response.success = True
self.context.response.answer = f"reviewed {len(summaries)} sessions"
self.context.response.metadata.update(
{
"num_session_files": len(session_files),
"num_reviewed_sessions": len(summaries),
"num_failed_reviews": len(failed_reviews),
"num_fallback_reviews": len(fallback_summaries),
"num_filtered_sessions": len(session_ids_illegal),
"output_path": str(output_path),
},
)
return self.context.response

View file

@ -1,31 +0,0 @@
system_prompt: |
You are an information extractor for the LongMemEval benchmark. You are given ONE chat session,
a target question, and its golden answer. Your job is to extract the complete information in
this session, especially anything related to the question or golden answer.
Rules:
- Extract all facts, names, entities, numbers, preferences, constraints, corrections, updates,
contradictions, plans, events, and outcomes that appear in the session.
- Be especially careful not to omit any information related to the question or golden answer.
- Keep time expressions inline with the fact they modify, including dates, weekdays, relative
times such as "last week" or "since January 15th", durations, and frequencies.
- Do not invent facts. Only extract what is actually present in the session.
- Do not judge whether answer_session_ids are correct. They are outside the answer audit scope.
- Output only the extracted information as plain text. Do not output JSON, markdown fences, or
relevance labels.
user_message: |
Question: {question}
Question type: {question_type}
Question date: {question_date}
Golden answer: {golden_answer}
--- Session under review ---
Session id: {session_id}
Session date: {session_date}
Session messages (JSON):
{session_content}
--- End of session ---
Extract the complete information from this session. Do not summarize away details, and do not
omit information related to the question or golden answer.

View file

@ -224,6 +224,22 @@ class AutoMemoryStep(BaseStep):
days = [day for msg in messages if (day := extract_daily_date(msg.created_at))]
return max(days) if days else None
def _build_messages(self, raw_messages: list) -> list[Msg]:
"""Convert raw message payloads into ``Msg`` objects.
Overridable hook: subclasses can preprocess ``raw_messages`` (e.g. fill
in missing timestamps) before conversion.
"""
return [self._to_msg(item) for item in raw_messages]
def _reply_extra_kwargs(self, day: str) -> dict: # pylint: disable=unused-argument
"""Extra keyword arguments for ``agent_wrapper.reply``.
Overridable hook: subclasses can inject additional reply options such
as per-tool defaults keyed on ``day``.
"""
return {}
# pylint: disable=too-many-return-statements
async def execute(self):
assert self.context is not None
@ -234,7 +250,7 @@ class AutoMemoryStep(BaseStep):
tz = self.app_context.app_config.timezone if self.app_context is not None else None
current = now(tz)
messages: list[Msg] = [self._to_msg(item) for item in raw_messages]
messages: list[Msg] = self._build_messages(raw_messages)
self.logger.info(
f"[{self.name}] start session_id={session_id!r} raw_messages={len(raw_messages)} "
f"messages={len(messages)} hint={bool(memory_hint)}",
@ -300,6 +316,7 @@ class AutoMemoryStep(BaseStep):
user_message,
system_prompt=self.prompt_format("system_prompt"),
job_tools=self.create_tools if created else self.update_tools,
**self._reply_extra_kwargs(day),
)
self.logger.info(f"[{self.name}] agent done path={note_path} has_result={bool(result.get('result'))}")

View file

@ -139,7 +139,21 @@ class SearchStep(BaseStep):
query: str = (self.context.get("query", "") or "").strip()
limit: int = int(self.context.get("limit") or _default_limit())
min_score: float = float(self.context.get("min_score") or 0.0)
vector_weight: float = float(self.kwargs.get("vector_weight", 0.7))
# vector_weight: prefer agent-supplied context value; fallback to YAML kwargs / default 0.7.
# Convertible numeric inputs are clipped to [0.0, 1.0]; non-numeric inputs are silently ignored.
raw_vw = self.context.get("vector_weight")
vector_weight: float | None = None
if raw_vw is not None:
try:
vector_weight = float(raw_vw)
except (TypeError, ValueError):
self.logger.warning(
f"[{self.name}] non-numeric vector_weight={raw_vw!r}; ignoring and using default 0.7",
)
vector_weight = None
if vector_weight is None:
vector_weight = float(self.kwargs.get("vector_weight", 0.7))
vector_weight = max(0.0, min(1.0, vector_weight))
candidate_multiplier: float = float(self.kwargs.get("candidate_multiplier", 5.0))
expand_links_enabled: bool = bool(self.kwargs.get("expand_links", True))
max_links_per_direction: int = int(self.kwargs.get("max_links_per_direction", 10))
@ -152,7 +166,6 @@ class SearchStep(BaseStep):
self.context.response.success = False
self.context.response.answer = "Error: query cannot be empty"
return self.context.response
assert 0.0 <= vector_weight <= 1.0, f"vector_weight must be in [0, 1], got {vector_weight}"
assert limit > 0, f"limit must be positive, got {limit}"
candidates = min(_MAX_CANDIDATES, max(1, int(limit * candidate_multiplier)))

View file

@ -1,425 +0,0 @@
"""Focused tests for the disputed LongMemEval final-answer workflow."""
import asyncio
import json
import sys
import threading
import time
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from benchmark.longmemeval import run_final_answer_review as driver_module
from benchmark.longmemeval.run_final_answer_review import (
REFERENCE_PATHS_ENV,
atomic_write_results,
merge_references,
select_question_ids,
)
from reme.components.agent_wrapper.base_agent_wrapper import BaseAgentWrapper
from reme.components.agent_wrapper.cc_agent_wrapper import CcAgentWrapper
from reme.components.application_context import ApplicationContext
from reme.config import resolve_app_config
from reme.steps.benchmark.lme import final_answer_review as review_module
from reme.steps.benchmark.lme.final_answer_review import FinalAnswerReviewStep
class _FakeAgentWrapper(BaseAgentWrapper):
"""Return queued ordinary text replies and retain every prompt call."""
def __init__(self, replies: list[str]):
super().__init__()
self.replies = list(replies)
self.calls: list[tuple[str, dict]] = []
async def reply(self, inputs, **kwargs) -> dict:
"""Return the next queued agent response."""
self.calls.append((inputs, kwargs))
return {
"session_id": f"attempt-{len(self.calls)}",
"result": self.replies.pop(0),
}
def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
def _write_jsonl(path: Path, rows: list[dict]) -> None:
path.write_text(
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
encoding="utf-8",
)
def _session(session_id: str, date: str, marker: str) -> dict:
return {
"haystack_session_id": session_id,
"haystack_date": date,
"messages": [{"role": "user", "content": marker}],
"other_session_field": f"full-{marker}",
}
def test_final_answer_review_keeps_raw_sessions_out_of_prompt_and_retries_plain_json(
tmp_path,
monkeypatch,
):
"""Raw session messages stay on disk, and invalid ordinary replies are retried."""
query = {
"question_id": "question-1",
"question": "What happened?",
"question_type": "single-session-user",
"question_date": "2024/01/02 (Tue) 10:00",
"extra_query_field": "keep-me",
}
golden = {
"answer": "old answer",
"answer_session_ids": ["past", "future"],
"extra_answer_field": "keep-me-too",
}
_write_json(tmp_path / "query.json", query)
_write_json(tmp_path / "answer.json", golden)
_write_json(
tmp_path / "session" / "past.json",
_session("past", "2024/01/02 (Tue) 09:59", "past-evidence"),
)
_write_json(
tmp_path / "session" / "equal.json",
_session("equal", "2024/01/02 (Tue) 10:00", "equal-evidence"),
)
_write_json(
tmp_path / "session" / "future.json",
_session("future", "2024/01/02 (Tue) 10:01", "future-secret"),
)
_write_jsonl(
tmp_path / "first.jsonl",
[
{
"question_id": "question-1",
"answer": "reference one",
"reason": "first reason",
},
],
)
_write_jsonl(
tmp_path / "second.jsonl",
[
{
"question_id": "question-1",
"answer": "reference two",
"reason": "second reason",
},
],
)
wrapper = _FakeAgentWrapper(
[
'{"reason":"missing fence","golden_answer_correct":false,"answer":"invalid",'
'"is_session_time_wrong":false}',
'```json\n{"reason":"deprecated timestamp verdict","golden_answer_correct":false,'
'"answer":"still invalid","is_session_time_wrong":true}\n```',
"补充分析可以放在代码块外。\n"
'```json\n{"reason":"由 past 和 equal 两个 session 支持 golden answer。",'
'"golden_answer_correct":true,"answer":"","is_session_time_wrong":false}\n```\n'
"审核完成。",
],
)
sleep = AsyncMock()
monkeypatch.setattr(review_module.asyncio, "sleep", sleep)
app_context = ApplicationContext(
workspace_dir=str(tmp_path),
resource_dir="session",
)
step = FinalAnswerReviewStep(
app_context=app_context,
agent_wrapper=wrapper,
reference_paths=["first.jsonl", "second.jsonl"],
retry_initial_seconds=0.01,
retry_max_seconds=0.02,
)
response = asyncio.run(step())
assert response.success is True
assert json.loads(response.answer) == {
"reason": "由 past 和 equal 两个 session 支持 golden answer。",
"golden_answer_correct": True,
"answer": "",
"is_session_time_wrong": False,
}
assert response.metadata["attempts"] == 3
assert response.metadata["num_sessions"] == 3
assert response.metadata["num_future_sessions"] == 1
assert response.metadata["future_sessions"] == [
{
"session_id": "future",
"session_date": "2024/01/02 (Tue) 10:01",
"session_file": "future.json",
},
]
assert len(wrapper.calls) == 3
prompt, reply_kwargs = wrapper.calls[0]
assert "past-evidence" not in prompt
assert "equal-evidence" not in prompt
assert "full-past-evidence" not in prompt
assert "future-secret" not in prompt
assert "extra_query_field" in prompt
assert "extra_answer_field" in prompt
assert "reference one" in prompt and "reference two" in prompt
assert '"session_time_check"' in prompt
assert '"sessions_after_question_date": [' in prompt
assert '"answer_session_ids_after_question_date"' not in prompt
assert '"future"' in prompt
assert "output_schema" not in reply_kwargs
assert [call.args for call in sleep.await_args_list] == [(0.01,), (0.02,)]
# pylint: disable=protected-access
def test_final_answer_review_reference_paths_env_overrides_config(tmp_path, monkeypatch):
"""The batch driver can pass its selected reference files into the job process."""
configured = tmp_path / "configured.jsonl"
selected = tmp_path / "selected.jsonl"
_write_jsonl(
configured,
[{"question_id": "question-1", "answer": "configured", "reason": "configured reason"}],
)
_write_jsonl(
selected,
[{"question_id": "question-1", "answer": "selected", "reason": "selected reason"}],
)
monkeypatch.setenv(REFERENCE_PATHS_ENV, json.dumps([str(selected)]))
step = FinalAnswerReviewStep(
app_context=ApplicationContext(workspace_dir=str(tmp_path)),
reference_paths=[str(configured)],
)
references = step._load_references("question-1")
assert len(references) == 1
assert references[0]["answer"] == "selected"
assert references[0]["source"] == selected.name
def test_final_answer_review_allows_question_without_reference_answer(tmp_path):
"""Samples outside the disputed lists are reviewed from answer.json alone."""
references_path = tmp_path / "references.jsonl"
_write_jsonl(
references_path,
[{"question_id": "another-question", "answer": "other", "reason": "other reason"}],
)
step = FinalAnswerReviewStep(
app_context=ApplicationContext(workspace_dir=str(tmp_path)),
reference_paths=[str(references_path)],
)
assert not step._load_references("question-without-reference")
# pylint: enable=protected-access
def test_final_answer_review_agent_cwd_is_sample_session_directory(tmp_path):
"""The configured relative cwd resolves inside each selected LME workspace."""
config = resolve_app_config(config="jinli_lme", log_config=False)
agent_config = config["components"]["agent_wrapper"]["lme_final_answer_review"]
assert agent_config["cwd"] == "session"
wrapper = CcAgentWrapper(
app_context=ApplicationContext(workspace_dir=str(tmp_path)),
cwd=agent_config["cwd"],
)
assert wrapper.cwd == tmp_path / "session"
# pylint: disable=protected-access
def test_final_answer_review_requires_empty_answer_when_golden_is_correct():
"""Correct golden answers are collected without duplicating their answer text."""
parsed = FinalAnswerReviewStep._parse_reply(
'```json\n{"reason":"golden is supported","golden_answer_correct":true,"answer":"",'
'"is_session_time_wrong":false}\n```',
)
assert parsed == {
"reason": "golden is supported",
"golden_answer_correct": True,
"answer": "",
"is_session_time_wrong": False,
}
with pytest.raises(ValueError, match="answer.*must be empty"):
FinalAnswerReviewStep._parse_reply(
'```json\n{"reason":"bad duplicate","golden_answer_correct":true,"answer":"duplicate",'
'"is_session_time_wrong":false}\n```',
)
with pytest.raises(ValueError, match="deprecated and must be false"):
FinalAnswerReviewStep._parse_reply(
'```json\n{"reason":"legacy session id verdict",'
'"golden_answer_correct":false,"answer":"corrected","is_session_time_wrong":true}\n```',
)
with pytest.raises(ValueError, match="must not evaluate answer_session_ids"):
FinalAnswerReviewStep._parse_reply(
'```json\n{"reason":"answer_session_ids contains a future session",'
'"golden_answer_correct":false,"answer":"corrected","is_session_time_wrong":false}\n```',
)
# pylint: enable=protected-access
def test_final_answer_review_rejects_unparseable_session_time_before_agent(tmp_path):
"""An unknown session time is never silently admitted across the time boundary."""
_write_json(
tmp_path / "query.json",
{
"question_id": "question-1",
"question": "Q",
"question_date": "2024/01/02 (Tue) 10:00",
},
)
_write_json(tmp_path / "answer.json", {"answer": "A"})
_write_json(
tmp_path / "session" / "bad.json",
_session("bad", "unknown", "must-not-reach-agent"),
)
_write_jsonl(
tmp_path / "refs.jsonl",
[{"question_id": "question-1", "answer": "reference", "reason": "reason"}],
)
valid_reply = "".join(
[
'```json\n{"reason":"y","golden_answer_correct":false,',
'"answer":"x","is_session_time_wrong":false}\n```',
],
)
wrapper = _FakeAgentWrapper([valid_reply])
step = FinalAnswerReviewStep(
app_context=ApplicationContext(
workspace_dir=str(tmp_path),
resource_dir="session",
),
agent_wrapper=wrapper,
reference_paths=["refs.jsonl"],
)
with pytest.raises(ValueError, match="Invalid LongMemEval datetime"):
asyncio.run(step())
assert not wrapper.calls
def test_driver_merges_references_and_atomically_rewrites_in_input_order(tmp_path):
"""The batch checkpoint contains one stable row per completed question."""
first = tmp_path / "first.jsonl"
second = tmp_path / "second.jsonl"
_write_jsonl(
first,
[
{"question_id": "q2", "answer": "a2", "reason": "r2"},
{"question_id": "q1", "answer": "a1", "reason": "r1"},
],
)
_write_jsonl(second, [{"question_id": "q1", "answer": "a1b", "reason": "r1b"}])
merged = merge_references([first, second])
assert list(merged) == ["q2", "q1"]
assert len(merged["q2"]) == 1
assert len(merged["q1"]) == 2
output = tmp_path / "result.jsonl"
atomic_write_results(
output,
list(merged),
{
"q1": {
"reason": "reason-1",
"golden_answer_correct": False,
"answer": "final-1",
"is_session_time_wrong": False,
},
"q2": {
"reason": "reason-2",
"golden_answer_correct": False,
"answer": "final-2",
"is_session_time_wrong": True,
},
},
)
rows = _read_output(output)
assert [row["question_id"] for row in rows] == ["q2", "q1"]
assert driver_module.load_existing(output)["q2"]["is_session_time_wrong"] is False
def test_driver_selects_all_or_explicit_question_ids(tmp_path):
"""Explicit IDs may select samples that have no reference-answer row."""
mapping = {
"q1": tmp_path / "0",
"q2": tmp_path / "1",
"q3": tmp_path / "2",
}
assert select_question_ids(mapping, None) == ["q1", "q2", "q3"]
assert select_question_ids(mapping, ["q3", "q1"]) == ["q3", "q1"]
assert select_question_ids(mapping, None, {"q1", "q3"}) == ["q2"]
assert select_question_ids(mapping, ["q3", "q2"], {"q3"}) == ["q2"]
with pytest.raises(ValueError, match="No dataset workspace"):
select_question_ids(mapping, ["unknown"])
with pytest.raises(ValueError, match="Duplicate"):
select_question_ids(mapping, ["q1", "q1"])
def test_driver_limits_concurrency_and_spaces_submissions(tmp_path, monkeypatch):
"""Concurrent jobs never exceed the cap and are not submitted in a burst."""
mapping = {f"q{index}": tmp_path / str(index) for index in range(4)}
starts: list[float] = []
active = 0
max_active = 0
lock = threading.Lock()
def fake_run_one(question_id, workspace, log_dir, reference_paths):
del question_id, workspace, log_dir, reference_paths
nonlocal active, max_active
with lock:
starts.append(time.monotonic())
active += 1
max_active = max(max_active, active)
time.sleep(0.055)
with lock:
active -= 1
return {
"reason": "reviewed",
"golden_answer_correct": True,
"answer": "",
"is_session_time_wrong": False,
}
monkeypatch.setattr(driver_module, "workspace_map", lambda: mapping)
monkeypatch.setattr(driver_module, "merge_references", lambda paths: {})
monkeypatch.setattr(driver_module, "load_existing", lambda path: {})
monkeypatch.setattr(driver_module, "atomic_write_results", lambda *args: None)
monkeypatch.setattr(driver_module, "run_one", fake_run_one)
monkeypatch.setattr(driver_module, "MIN_SUBMIT_INTERVAL_SECONDS", 0.0)
monkeypatch.setattr(
sys,
"argv",
[
"run_final_answer_review.py",
"--concurrency",
"3",
"--submit-interval-seconds",
"0.02",
"--output",
str(tmp_path / "output.jsonl"),
],
)
assert driver_module.main() == 0
assert max_active == 3
assert len(starts) == 4
assert all(later - earlier >= 0.015 for earlier, later in zip(starts, starts[1:]))
def _read_output(path: Path) -> list[dict]:
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]