mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-22 00:32:49 +00:00
feat(file_watcher): add directory deletion support with descendant indexing Add support for deleting entire directories and their indexed descendants in the file watcher. Previously only individual file deletions were handled properly. Now when a directory is deleted, the system finds all indexed files beneath that directory path and removes them along with their metadata and chunks. The implementation includes: - New `_descendant_indexed_paths` method to find all indexed files under a given directory path - Updated `_on_deleted` method to process both the target path and all its indexed descendants - Proper handling of symlinks and path resolution differences - Enhanced logging to show directory deletion with child count Also adds necessary os import for path operations. refactor(config): restructure configuration profiles for clarity Rename curated.yaml to remove outdated configuration file and rename full.yaml to expert.yaml with updated documentation. Add new service.yaml configuration profile that provides a service-aligned MCP surface with three main tools: - retrieve: graph-aware hybrid retrieval - remember: single write entry point with log/distill modes - maintain: vault hygiene sweep The expert configuration now excludes the ingest tool since cold-path operations are handled by external agents, and adds memory_lint tool for structural issue detection. Updated documentation to clarify the different configuration profiles and their intended usage patterns. ```
231 lines
9.2 KiB
Python
231 lines
9.2 KiB
Python
"""Service-profile MCP tests.
|
|
|
|
Covers the 3-tool service surface (`reme2/config/service.yaml`):
|
|
|
|
retrieve — graph-aware hybrid retrieval (memory_graph_search backend)
|
|
remember — single write entry point (Ingestor projection):
|
|
mode=log → zero-LLM event-folder upsert
|
|
mode=distill → LLM R-M-W (degraded path here, no LLM)
|
|
maintain — vault hygiene sweep (Maintainer projection)
|
|
|
|
Same shape as `test_expert.py`: each `check_*` is a one-job-or-sequence
|
|
async function returning a one-line summary. Ordering matters because
|
|
`remember.log_append` and `remember.log_refuse_distilled` rely on side
|
|
effects from `remember.log_create`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from ._helpers import AppContext, decode, wait_for_index
|
|
|
|
|
|
EXPECTED_JOBS: tuple[str, ...] = ("retrieve", "remember", "maintain")
|
|
|
|
|
|
# ---------- registry ---------------------------------------------------
|
|
|
|
|
|
async def check_registry(ctx: AppContext) -> str:
|
|
missing = [j for j in EXPECTED_JOBS if j not in ctx.jobs]
|
|
assert not missing, f"missing jobs: {missing}"
|
|
extras = [j for j in ctx.jobs if j not in EXPECTED_JOBS]
|
|
assert not extras, (
|
|
f"curated profile leaked extra jobs: {extras} — keep the surface tight"
|
|
)
|
|
return f"{len(ctx.jobs)} jobs registered"
|
|
|
|
|
|
# ---------- read: retrieve (graph-aware hybrid) -----------------------
|
|
|
|
|
|
async def check_retrieve_basic(ctx: AppContext) -> str:
|
|
r = decode(await ctx.app.run_job(
|
|
"retrieve", query="Alice Bob", max_results=5, min_score=0.0,
|
|
))
|
|
hits = r if isinstance(r, list) else (r.get("chunks") if isinstance(r, dict) else [])
|
|
assert len(hits) > 0, r
|
|
return f"{len(hits)} hits"
|
|
|
|
|
|
async def check_retrieve_anchored(ctx: AppContext) -> str:
|
|
"""Wikilink-anchored mode — `[[Project X]]` in the query seeds BFS."""
|
|
r = decode(await ctx.app.run_job(
|
|
"retrieve",
|
|
query="What touches [[Project X]]?",
|
|
max_results=5,
|
|
min_score=0.0,
|
|
graph_depth=1,
|
|
))
|
|
hits = r if isinstance(r, list) else (r.get("chunks") if isinstance(r, dict) else [])
|
|
assert len(hits) > 0, r
|
|
paths = {h.get("path") for h in hits if isinstance(h, dict)}
|
|
# Project X's neighbors (Alice, Bob) should surface via 1-hop BFS
|
|
has_neighbor = any(p and ("Alice" in p or "Bob" in p) for p in paths)
|
|
assert has_neighbor, f"BFS didn't pull in [[Project X]] neighbors: {paths}"
|
|
return f"{len(hits)} hits incl. project-x neighbors"
|
|
|
|
|
|
async def check_retrieve_topic_seeded(ctx: AppContext) -> str:
|
|
"""Topic-rooted mode — explicit `seeds=[...]` instead of inline wikilink."""
|
|
project_x = ctx.abs_path("topics", "Project X", "Project X.md")
|
|
r = decode(await ctx.app.run_job(
|
|
"retrieve",
|
|
query="collaborates",
|
|
max_results=5,
|
|
min_score=0.0,
|
|
seeds=[project_x],
|
|
graph_depth=1,
|
|
))
|
|
hits = r if isinstance(r, list) else (r.get("chunks") if isinstance(r, dict) else [])
|
|
assert len(hits) > 0, r
|
|
return f"{len(hits)} hits (seeded at Project X)"
|
|
|
|
|
|
# ---------- write: remember (mode=log: create / append / refusal) -----
|
|
|
|
|
|
async def check_remember_log_create(ctx: AppContext) -> str:
|
|
r = decode(await ctx.app.run_job(
|
|
"remember",
|
|
mode="log",
|
|
name="curated-event",
|
|
description="curated-profile suite",
|
|
content="## ops\n- ran the curated suite\n",
|
|
topics=["[[Alice]]"],
|
|
tags=["curated"],
|
|
materials=[
|
|
{"filename": "raw-prompt.md", "content": "# user prompt\n\nrun curated suite\n"},
|
|
{"filename": "tool-output.txt", "content": "exit=0\n"},
|
|
],
|
|
))
|
|
assert isinstance(r, dict), r
|
|
assert r.get("created") is True and r.get("action") == "created", r
|
|
materials = r.get("materials", [])
|
|
assert len(materials) == 2, materials
|
|
for m in materials:
|
|
assert Path(m).is_file(), f"material missing on disk: {m}"
|
|
event_dir = Path(materials[0]).parent
|
|
index_text = (event_dir / "curated-event.md").read_text(encoding="utf-8")
|
|
assert "## Materials" in index_text, "Materials footer absent"
|
|
assert "lifecycle: streaming" in index_text, "schema axis 'lifecycle' missing"
|
|
assert "role: observation" in index_text, "schema axis 'role' missing"
|
|
setattr(ctx, "_event_dir", event_dir)
|
|
setattr(ctx, "_event_index", event_dir / "curated-event.md")
|
|
await wait_for_index(ctx.watcher, expected_min=len(ctx.file_store))
|
|
return f"created folder w/ {len(materials)} materials"
|
|
|
|
|
|
async def check_remember_log_append(ctx: AppContext) -> str:
|
|
r = decode(await ctx.app.run_job(
|
|
"remember",
|
|
mode="log",
|
|
name="curated-event",
|
|
content="## follow-up\n- second pass\n",
|
|
topics=["[[Bob]]"],
|
|
tags=["follow-up"],
|
|
materials=[
|
|
{"filename": "tool-output.txt", "content": "second run\n"}, # collision
|
|
],
|
|
))
|
|
assert isinstance(r, dict), r
|
|
assert r.get("created") is False and r.get("action") == "appended", r
|
|
appended = r.get("materials", [])
|
|
names = {Path(p).name for p in appended}
|
|
assert "tool-output-2.txt" in names, f"collision auto-suffix failed: {names}"
|
|
index_text = getattr(ctx, "_event_index").read_text(encoding="utf-8")
|
|
assert "## Update —" in index_text, "Update section missing"
|
|
assert "[[Bob]]" in index_text, "topic union failed"
|
|
return f"appended w/ collision → {sorted(names)}"
|
|
|
|
|
|
async def check_remember_log_refuse_distilled(ctx: AppContext) -> str:
|
|
"""Curated profile lacks `memory_property_update`, so we flip status by
|
|
rewriting the file directly — same observable effect on remember(mode=log)."""
|
|
index_path = Path(getattr(ctx, "_event_index"))
|
|
text = index_path.read_text(encoding="utf-8")
|
|
text = text.replace("status: active", "status: distilled", 1)
|
|
index_path.write_text(text, encoding="utf-8")
|
|
# Give the watcher a moment to re-parse before sync re-reads frontmatter.
|
|
await wait_for_index(ctx.watcher, expected_min=len(ctx.file_store))
|
|
|
|
r = decode(await ctx.app.run_job(
|
|
"remember", mode="log", name="curated-event", content="should be refused",
|
|
))
|
|
assert isinstance(r, dict) and "error" in r, r
|
|
assert r.get("status") == "distilled", r
|
|
assert r.get("suggested_name"), r
|
|
return f"refused → suggested={r['suggested_name']!r}"
|
|
|
|
|
|
# ---------- write: remember (mode=distill, degraded path) -------------
|
|
|
|
|
|
async def check_remember_distill_degraded(ctx: AppContext) -> str:
|
|
target = ctx.abs_path("topics", "curated-ingested", "curated-ingested.md")
|
|
r = decode(await ctx.app.run_job(
|
|
"remember",
|
|
# mode defaults to "distill"
|
|
content="# curated-ingested\n\nproduced by the curated test suite.\n",
|
|
target_path=target,
|
|
metadata={
|
|
"title": "curated-ingested",
|
|
"lifecycle": "evolving",
|
|
"scope": "class",
|
|
"source": "curated",
|
|
"role": "concept",
|
|
"category": "concept",
|
|
},
|
|
))
|
|
assert isinstance(r, dict), r
|
|
applied = r.get("applied") or []
|
|
assert len(applied) == 1 and applied[0].get("ok") is True, r
|
|
assert r.get("used_llm") is False, "expected degraded path (no LLM)"
|
|
assert Path(target).is_file(), target
|
|
return f"applied=1, used_llm=False"
|
|
|
|
|
|
# ---------- maintain (lint + decay sweep) -----------------------------
|
|
|
|
|
|
async def check_maintain_dry_run(ctx: AppContext) -> str:
|
|
"""Default ops=[lint,decay] with dry_run=true (the parameter default)
|
|
surfaces the plan without mutating. The seeded vault has nothing
|
|
stale and no broken wikilinks, so we just verify the envelope."""
|
|
r = decode(await ctx.app.run_job("maintain"))
|
|
assert isinstance(r, dict), r
|
|
for key in ("ops_run", "scanned", "proposed", "plan", "applied", "dry_run"):
|
|
assert key in r, f"missing key {key!r} in audit: {r}"
|
|
assert r["dry_run"] is True, r
|
|
assert r["ops_run"] == ["lint", "decay"], r
|
|
assert r["applied"] == [], "dry_run should never apply"
|
|
return f"scanned={r['scanned']}, proposed={len(r['proposed'])}"
|
|
|
|
|
|
async def check_maintain_targeted(ctx: AppContext) -> str:
|
|
"""target_prefix narrows scan; events/ subtree should yield the
|
|
one event folder created earlier in this suite."""
|
|
r = decode(await ctx.app.run_job(
|
|
"maintain", target_prefix="events/", dry_run=True,
|
|
))
|
|
assert isinstance(r, dict), r
|
|
assert r["scanned"] >= 1, r # at least the suite's own event folder
|
|
return f"scanned={r['scanned']} under events/"
|
|
|
|
|
|
# ---------- ordered manifest -----------------------------------------
|
|
|
|
|
|
CHECKS: list[tuple[str, callable]] = [
|
|
("registry", check_registry),
|
|
("retrieve.basic", check_retrieve_basic),
|
|
("retrieve.anchored", check_retrieve_anchored),
|
|
("retrieve.topic_seeded", check_retrieve_topic_seeded),
|
|
("remember.log_create", check_remember_log_create),
|
|
("remember.log_append", check_remember_log_append),
|
|
("remember.log_refuse_distilled", check_remember_log_refuse_distilled),
|
|
("remember.distill_degraded", check_remember_distill_degraded),
|
|
("maintain.dry_run", check_maintain_dry_run),
|
|
("maintain.targeted", check_maintain_targeted),
|
|
]
|