mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-08 03:10:24 +00:00
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>
382 lines
16 KiB
Python
382 lines
16 KiB
Python
"""auto_memory — record conversation facts into a daily note via an agent."""
|
|
|
|
from pathlib import Path
|
|
|
|
import aiofiles
|
|
import frontmatter
|
|
from agentscope.message import Msg
|
|
|
|
from ._evolve import agent_reply_result_text, format_history, now
|
|
from ..base_step import BaseStep
|
|
from ..file_io import extract_daily_date, parse_daily_date, refresh_day_index
|
|
from ..file_io import validate_filename_component, validate_session_id
|
|
from ...components import R
|
|
|
|
_SESSION_ID_KEY = "session_id"
|
|
_SOURCE_CONVERSATION_KEY = "source_conversation"
|
|
_MESSAGE_TIME_ALIASES = ("time_created", "timestamp", "createdAt", "timeCreated", "created_time")
|
|
|
|
|
|
def _sanitize_msg_for_save(msg: Msg) -> Msg:
|
|
new_content = []
|
|
changed = False
|
|
for block in msg.content:
|
|
# Tool results often contain recalled memory/search/read output. Keeping
|
|
# them in saved conversation history lets retrieved facts masquerade as
|
|
# user-provided context in future auto-memory runs.
|
|
if block.type == "tool_result":
|
|
changed = True
|
|
continue
|
|
if block.type == "data" and hasattr(block, "source") and getattr(block.source, "type", None) == "base64":
|
|
changed = True
|
|
continue
|
|
new_content.append(block)
|
|
if not changed:
|
|
return msg
|
|
return msg.model_copy(update={"content": new_content})
|
|
|
|
|
|
def _normalize_msg_timestamp(item: dict) -> dict:
|
|
"""Map common message timestamp aliases to AgentScope's ``created_at`` field."""
|
|
if item.get("created_at"):
|
|
return item
|
|
|
|
for key in _MESSAGE_TIME_ALIASES:
|
|
value = item.get(key)
|
|
if value:
|
|
return {**item, "created_at": value}
|
|
|
|
metadata = item.get("metadata")
|
|
if isinstance(metadata, dict):
|
|
for key in _MESSAGE_TIME_ALIASES:
|
|
value = metadata.get(key)
|
|
if value:
|
|
return {**item, "created_at": value}
|
|
|
|
return item
|
|
|
|
|
|
@R.register("auto_memory_step")
|
|
class AutoMemoryStep(BaseStep):
|
|
"""Record conversation facts into a daily note via an Agent."""
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.create_tools: list[str] = ["daily_write"]
|
|
self.update_tools: list[str] = ["read", "edit", "frontmatter_update", "write"]
|
|
|
|
def _session_dir(self) -> str:
|
|
return str(self.config_value("session_dir")).strip("/")
|
|
|
|
def _session_path(self, session_id: str) -> Path:
|
|
return self.file_store.workspace_path / self._session_dir() / "dialog" / f"{session_id}.jsonl"
|
|
|
|
def _session_link(self, session_id: str) -> str:
|
|
return f"[[{self._session_dir()}/dialog/{session_id}.jsonl]]"
|
|
|
|
def _daily_note_path(self, day: str, name: str) -> str:
|
|
return f"{self.config_value('daily_dir')}/{day}/{name}.md"
|
|
|
|
def _frontmatter(self, path: str) -> dict:
|
|
post = frontmatter.loads((self.file_store.workspace_path / path).read_text(encoding="utf-8"))
|
|
return dict(post.metadata or {})
|
|
|
|
def _note_bytes(self, path: str) -> bytes | None:
|
|
note_path = self.file_store.workspace_path / path
|
|
if not note_path.is_file():
|
|
return None
|
|
return note_path.read_bytes()
|
|
|
|
def _note_modified(self, before_path: str, before_bytes: bytes | None, after_path: str) -> bool:
|
|
if not after_path:
|
|
return False
|
|
after_bytes = self._note_bytes(after_path)
|
|
if after_bytes is None:
|
|
return before_bytes is not None
|
|
return after_path != before_path or before_bytes != after_bytes
|
|
|
|
def _find_session_note(self, notes: list[dict], session_id: str) -> dict | None:
|
|
source = self._session_link(session_id)
|
|
for note in notes:
|
|
if str(note.get(_SESSION_ID_KEY, "")).strip() == session_id:
|
|
return note
|
|
for note in notes:
|
|
if str(note.get(_SOURCE_CONVERSATION_KEY, "")).strip() == source:
|
|
return note
|
|
return None
|
|
|
|
async def _list_session_note(self, day: str, session_id: str) -> dict | None:
|
|
list_response = await self.run_job("daily_list", date=day)
|
|
if not list_response.success:
|
|
raise RuntimeError(f"daily_list failed: {list_response.answer}")
|
|
notes = list_response.metadata.get("notes") or []
|
|
return self._find_session_note(notes, session_id)
|
|
|
|
async def _ensure_session_frontmatter(self, path: str, session_id: str) -> None:
|
|
metadata = {
|
|
_SESSION_ID_KEY: session_id,
|
|
_SOURCE_CONVERSATION_KEY: self._session_link(session_id),
|
|
}
|
|
current = self._frontmatter(path)
|
|
if all(current.get(key) == value for key, value in metadata.items()):
|
|
return
|
|
response = await self.run_job(
|
|
"frontmatter_update",
|
|
path=path,
|
|
metadata=metadata,
|
|
)
|
|
if not response.success:
|
|
raise RuntimeError(f"frontmatter_update failed: {response.answer}")
|
|
|
|
async def _rename_from_frontmatter_name(self, path: str, day: str) -> str:
|
|
meta = self._frontmatter(path)
|
|
name = str(meta.get("name", "")).strip()
|
|
if not name:
|
|
return path
|
|
if err := validate_filename_component(name, kind="name"):
|
|
raise RuntimeError(err)
|
|
|
|
target_path = self._daily_note_path(day, name)
|
|
if target_path == path:
|
|
return path
|
|
|
|
move_response = await self.run_job(
|
|
"move",
|
|
src_path=path,
|
|
dst_path=target_path,
|
|
overwrite=False,
|
|
retarget=True,
|
|
)
|
|
if not move_response.success:
|
|
raise RuntimeError(f"move failed: {move_response.answer}")
|
|
return target_path
|
|
|
|
async def _save_session_messages(self, session_id: str, messages: list[Msg]) -> None:
|
|
if not session_id or not messages:
|
|
return
|
|
|
|
path = self._session_path(session_id)
|
|
self.logger.info(
|
|
f"[{self.name}] save session start session_id={session_id!r} messages={len(messages)} path={path}",
|
|
)
|
|
|
|
existing: list[Msg] = []
|
|
if path.exists():
|
|
async with aiofiles.open(path, encoding="utf-8") as f:
|
|
content = await f.read()
|
|
for line in content.splitlines():
|
|
line = line.strip()
|
|
if line:
|
|
try:
|
|
existing.append(Msg.model_validate_json(line))
|
|
except Exception:
|
|
pass
|
|
|
|
by_id: dict[str, Msg] = {}
|
|
for msg in existing:
|
|
by_id[msg.id] = msg
|
|
for msg in messages:
|
|
by_id[msg.id] = msg
|
|
merged = sorted(by_id.values(), key=lambda m: m.created_at)
|
|
|
|
can_append = 0 < len(existing) <= len(merged) and all(
|
|
merged[i].id == existing[i].id for i in range(len(existing))
|
|
)
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if can_append:
|
|
new_msgs = merged[len(existing) :]
|
|
if new_msgs:
|
|
async with aiofiles.open(path, "a", encoding="utf-8") as f:
|
|
for msg in new_msgs:
|
|
await f.write(_sanitize_msg_for_save(msg).model_dump_json() + "\n")
|
|
self.logger.info(
|
|
f"[{self.name}] save session appended session_id={session_id!r} "
|
|
f"existing={len(existing)} appended={len(new_msgs)} total={len(merged)}",
|
|
)
|
|
else:
|
|
self.logger.info(
|
|
f"[{self.name}] save session unchanged session_id={session_id!r} "
|
|
f"existing={len(existing)} total={len(merged)}",
|
|
)
|
|
else:
|
|
async with aiofiles.open(path, "w", encoding="utf-8") as f:
|
|
for msg in merged:
|
|
await f.write(_sanitize_msg_for_save(msg).model_dump_json() + "\n")
|
|
self.logger.info(
|
|
f"[{self.name}] save session rewrote session_id={session_id!r} "
|
|
f"existing={len(existing)} total={len(merged)}",
|
|
)
|
|
|
|
@staticmethod
|
|
def _to_msg(item) -> Msg:
|
|
if isinstance(item, Msg):
|
|
return item
|
|
if isinstance(item, dict):
|
|
item = _normalize_msg_timestamp(item)
|
|
if isinstance(item, dict) and isinstance(item.get("content"), str):
|
|
item = {**item, "content": [{"type": "text", "text": item["content"]}]}
|
|
return Msg.model_validate(item)
|
|
|
|
@staticmethod
|
|
def _messages_day(messages: list[Msg]) -> str | None:
|
|
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
|
|
raw_messages = self.context.get("messages") or []
|
|
session_id: str = self.context.get("session_id", "")
|
|
memory_hint: str = self.context.get("memory_hint", "")
|
|
raw_date = self.context.get("date", "")
|
|
tz = self.app_context.app_config.timezone if self.app_context is not None else None
|
|
current = now(tz)
|
|
|
|
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)}",
|
|
)
|
|
|
|
if session_id and (err := validate_session_id(session_id)):
|
|
self.context.response.success = False
|
|
self.context.response.answer = f"Error: {err}"
|
|
self.logger.warning(f"[{self.name}] invalid session_id={session_id!r} err={err}")
|
|
return
|
|
if not session_id:
|
|
self.context.response.success = False
|
|
self.context.response.answer = "Error: session_id is required"
|
|
self.logger.warning(f"[{self.name}] missing session_id")
|
|
return
|
|
|
|
day = parse_daily_date(raw_date) if raw_date else self._messages_day(messages) or current.strftime("%Y-%m-%d")
|
|
if raw_date and day is None:
|
|
self.context.response.success = False
|
|
self.context.response.answer = "Error: date must be YYYY-MM-DD"
|
|
self.context.response.metadata.update({"date": raw_date, "modified": False, "n_messages": len(messages)})
|
|
self.logger.warning(f"[{self.name}] invalid date={raw_date!r}")
|
|
return
|
|
|
|
await self._save_session_messages(session_id, messages)
|
|
|
|
if not messages:
|
|
self.context.response.success = True
|
|
self.context.response.answer = "Skipped: no messages"
|
|
self.context.response.metadata.update({"date": day, "modified": False, "n_messages": 0})
|
|
self.logger.info(f"[{self.name}] Skipped: no messages session_id={session_id!r} modified=False")
|
|
return
|
|
|
|
try:
|
|
note = await self._list_session_note(day, session_id)
|
|
except RuntimeError as exc:
|
|
self.context.response.success = False
|
|
self.context.response.answer = str(exc)
|
|
self.context.response.metadata.update({"date": day, "modified": False, "n_messages": len(messages)})
|
|
self.logger.info(f"[{self.name}] list failed session_id={session_id!r} answer={str(exc)!r}")
|
|
return
|
|
|
|
note_path = str(note["path"]) if note else ""
|
|
created = note is None
|
|
before_note_path = note_path
|
|
before_note_bytes = self._note_bytes(note_path) if note_path else None
|
|
self.logger.info(
|
|
f"[{self.name}] note lookup session_id={session_id!r} path={note_path!r} "
|
|
f"created={created} msgs={len(messages)} hint={bool(memory_hint)}",
|
|
)
|
|
template_key = "user_message_create" if created else "user_message_update"
|
|
user_message = self.prompt_format(
|
|
template_key,
|
|
today=day,
|
|
note=memory_hint or "(none)",
|
|
note_path=note_path,
|
|
session_id=session_id,
|
|
history=format_history(messages),
|
|
)
|
|
|
|
self.logger.info(f"[{self.name}] agent start path={note_path} template={template_key}")
|
|
result = await self.agent_wrapper.reply(
|
|
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'))}")
|
|
|
|
if created:
|
|
try:
|
|
note = await self._list_session_note(day, session_id)
|
|
except RuntimeError as exc:
|
|
self.context.response.success = False
|
|
self.context.response.answer = str(exc)
|
|
self.context.response.metadata.update(
|
|
{"date": day, "path": None, "created": created, "modified": False, "n_messages": len(messages)},
|
|
)
|
|
self.logger.info(f"[{self.name}] post-create list failed session_id={session_id!r} answer={str(exc)!r}")
|
|
return
|
|
if note is None:
|
|
self.context.response.success = True
|
|
self.context.response.answer = agent_reply_result_text(result)
|
|
self.context.response.metadata.update(
|
|
{"date": day, "path": None, "created": False, "modified": False, "n_messages": len(messages)},
|
|
)
|
|
self.logger.info(f"[{self.name}] done without note session_id={session_id!r} modified=False")
|
|
return
|
|
note_path = str(note["path"])
|
|
else:
|
|
try:
|
|
await self._ensure_session_frontmatter(note_path, session_id)
|
|
note_path = await self._rename_from_frontmatter_name(note_path, day)
|
|
except RuntimeError as exc:
|
|
self.context.response.success = False
|
|
self.context.response.answer = str(exc)
|
|
self.context.response.metadata.update(
|
|
{
|
|
"date": day,
|
|
"path": note_path,
|
|
"created": created,
|
|
"modified": self._note_modified(before_note_path, before_note_bytes, note_path),
|
|
"n_messages": len(messages),
|
|
},
|
|
)
|
|
self.logger.info(f"[{self.name}] post-update failed path={note_path} answer={str(exc)!r}")
|
|
return
|
|
|
|
modified = self._note_modified(before_note_path, before_note_bytes, note_path)
|
|
daily_dir = self.config_value("daily_dir")
|
|
self.logger.info(f"[{self.name}] refresh index start date={day} daily_dir={daily_dir}")
|
|
index_payload = await refresh_day_index(self.file_store, day, daily_dir)
|
|
self.logger.info(f"[{self.name}] refresh index done path={note_path}")
|
|
|
|
source_conversation = self._session_link(session_id)
|
|
self.context.response.success = True
|
|
self.context.response.answer = agent_reply_result_text(result)
|
|
self.context.response.metadata.update(
|
|
{
|
|
"date": day,
|
|
"path": note_path,
|
|
"created": created,
|
|
"modified": modified,
|
|
"n_messages": len(messages),
|
|
"source_conversation": source_conversation,
|
|
"index": index_payload,
|
|
},
|
|
)
|
|
self.logger.info(f"[{self.name}] done {note_path} modified={modified}")
|