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. ```
425 lines
17 KiB
Python
425 lines
17 KiB
Python
"""Expert-profile MCP tests.
|
|
|
|
Covers all 16 jobs registered by `reme2/config/expert.yaml`:
|
|
|
|
Hot-write sync
|
|
Read memory_search, memory_graph_search,
|
|
memory_get, memory_list, memory_links,
|
|
memory_backlinks, memory_resolve_wikilink,
|
|
memory_count_tokens, memory_lint
|
|
Raw write memory_create, memory_update, memory_property_update,
|
|
memory_rename, memory_delete, memory_archive
|
|
|
|
Each `check_*` is an `async def` that takes a populated `AppContext`,
|
|
runs one MCP job (or a small sequence), asserts on the response, and
|
|
returns a one-line summary string. The orchestrator runs them in the
|
|
listed order and collects pass/fail.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from ._helpers import AppContext, decode, wait_for_index
|
|
|
|
|
|
# Manifest of every tool the expert profile must expose. Used by
|
|
# `check_registry` and as the upper bound for `wait_for_index` budgets.
|
|
EXPECTED_JOBS: tuple[str, ...] = (
|
|
"sync",
|
|
"memory_search",
|
|
"memory_graph_search",
|
|
"memory_get",
|
|
"memory_list",
|
|
"memory_backlinks",
|
|
"memory_links",
|
|
"memory_resolve_wikilink",
|
|
"memory_count_tokens",
|
|
"memory_lint",
|
|
"memory_create",
|
|
"memory_update",
|
|
"memory_property_update",
|
|
"memory_rename",
|
|
"memory_delete",
|
|
"memory_archive",
|
|
)
|
|
|
|
|
|
# ---------- 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]
|
|
return f"{len(ctx.jobs)} jobs registered (extras: {extras or 'none'})"
|
|
|
|
|
|
# ---------- read primitives -------------------------------------------
|
|
|
|
|
|
async def check_memory_get(ctx: AppContext) -> str:
|
|
alice = ctx.abs_path("topics", "Alice", "Alice.md")
|
|
r = decode(await ctx.app.run_job("memory_get", path=alice))
|
|
assert isinstance(r, dict) and r.get("exists") is True, r
|
|
assert "metadata" in r, "missing metadata block"
|
|
return f"exists=True, edges={len(r.get('link', []))}"
|
|
|
|
|
|
async def check_memory_list(ctx: AppContext) -> str:
|
|
r = decode(await ctx.app.run_job("memory_list", tags=["person"]))
|
|
assert isinstance(r, dict) and r.get("count", 0) >= 2, r
|
|
paths = {item.get("path") for item in r.get("items", [])}
|
|
assert any("Alice.md" in p for p in paths), paths
|
|
return f"count={r['count']}"
|
|
|
|
|
|
async def check_memory_search(ctx: AppContext) -> str:
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_search", query="collaborates", max_results=3, 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_memory_graph_search(ctx: AppContext) -> str:
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_graph_search", query="Alice", 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
|
|
# At least one result should carry a graph_hop annotation
|
|
hops = {h.get("graph_hop") for h in hits if isinstance(h, dict)}
|
|
return f"{len(hits)} hits, hops seen={sorted(h for h in hops if h is not None)}"
|
|
|
|
|
|
async def check_memory_links(ctx: AppContext) -> str:
|
|
alice = ctx.abs_path("topics", "Alice", "Alice.md")
|
|
r = decode(await ctx.app.run_job("memory_links", path=alice))
|
|
assert isinstance(r, dict) and len(r.get("links", [])) >= 2, r
|
|
return f"{len(r['links'])} resolved outgoing links"
|
|
|
|
|
|
async def check_memory_backlinks(ctx: AppContext) -> str:
|
|
alice = ctx.abs_path("topics", "Alice", "Alice.md")
|
|
r = decode(await ctx.app.run_job("memory_backlinks", path=alice))
|
|
# Bob.md and Project X.md both link to [[Alice]]
|
|
assert isinstance(r, dict) and len(r.get("backlinks", [])) >= 2, r
|
|
return f"{len(r['backlinks'])} incoming backlinks"
|
|
|
|
|
|
async def check_memory_resolve_wikilink(ctx: AppContext) -> str:
|
|
r = decode(await ctx.app.run_job("memory_resolve_wikilink", wikilink="Alice"))
|
|
assert isinstance(r, dict) and r.get("exists") is True, r
|
|
assert "Alice.md" in (r.get("path") or ""), r
|
|
return "stem 'Alice' → Alice.md"
|
|
|
|
|
|
async def check_memory_count_tokens(ctx: AppContext) -> str:
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_count_tokens", text="hello world from the smoke test",
|
|
))
|
|
assert isinstance(r, dict) and isinstance(r.get("tokens"), int), r
|
|
assert r["tokens"] > 0, r
|
|
return f"text → {r['tokens']} tokens"
|
|
|
|
|
|
async def check_memory_lint(ctx: AppContext) -> str:
|
|
"""Maintainer lint pass — read-only. The seeded vault is healthy
|
|
(no broken wikilinks / schema violations), so we just verify the
|
|
shell wires through and returns the expected envelope."""
|
|
r = decode(await ctx.app.run_job("memory_lint"))
|
|
assert isinstance(r, dict), r
|
|
assert "scanned" in r and "findings" in r, r
|
|
assert isinstance(r["findings"], list), r
|
|
assert r["scanned"] >= len(ctx.file_store), r
|
|
return f"scanned={r['scanned']}, findings={len(r['findings'])}"
|
|
|
|
|
|
# ---------- hot-write: sync (create / append / refusal) ---------------
|
|
|
|
|
|
async def check_sync_create(ctx: AppContext) -> str:
|
|
r = decode(await ctx.app.run_job(
|
|
"sync",
|
|
name="suite-event",
|
|
description="full-profile suite",
|
|
content="## ops\n- ran the suite\n",
|
|
topics=["[[Alice]]"],
|
|
tags=["suite"],
|
|
materials=[
|
|
{"filename": "raw-prompt.md", "content": "# user prompt\n\nrun 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 / "suite-event.md").read_text(encoding="utf-8")
|
|
assert "## Materials" in index_text, "Materials footer absent"
|
|
assert "raw-prompt.md" in index_text, "Materials footer missing raw-prompt link"
|
|
# 4-axis schema must be on disk
|
|
assert "lifecycle: streaming" in index_text, "schema axis 'lifecycle' missing"
|
|
assert "role: observation" in index_text, "schema axis 'role' missing"
|
|
# Stash for downstream checks via the context (small mutation pattern).
|
|
ctx.abs_path("__suite_event_dir__") # noop; readable side-effect below
|
|
setattr(ctx, "_suite_event_dir", event_dir)
|
|
setattr(ctx, "_suite_event_index", event_dir / "suite-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_sync_append(ctx: AppContext) -> str:
|
|
r = decode(await ctx.app.run_job(
|
|
"sync",
|
|
name="suite-event",
|
|
content="## follow-up\n- second pass\n",
|
|
topics=["[[Bob]]"], # union with [[Alice]]
|
|
tags=["follow-up"],
|
|
materials=[
|
|
{"filename": "tool-output.txt", "content": "second run\n"}, # collision
|
|
{"filename": "summary.md", "content": "# summary\n"},
|
|
],
|
|
))
|
|
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}"
|
|
assert "summary.md" in names, f"new material missing: {names}"
|
|
index_text = getattr(ctx, "_suite_event_index").read_text(encoding="utf-8")
|
|
assert "## ops" in index_text, "original body lost"
|
|
assert "## Update —" in index_text, "Update section missing"
|
|
assert "[[Bob]]" in index_text, "topic union failed"
|
|
assert "tool-output-2.txt" in index_text, "Materials footer lost the collided file"
|
|
return f"appended w/ collision → {sorted(names)}"
|
|
|
|
|
|
async def check_sync_refuse_distilled(ctx: AppContext) -> str:
|
|
index_path = str(getattr(ctx, "_suite_event_index"))
|
|
await ctx.app.run_job(
|
|
"memory_property_update", path=index_path, key="status", value="distilled",
|
|
)
|
|
r = decode(await ctx.app.run_job(
|
|
"sync", name="suite-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}"
|
|
|
|
|
|
# ---------- raw memory_* writes ---------------------------------------
|
|
|
|
|
|
async def check_memory_create(ctx: AppContext) -> str:
|
|
target = ctx.abs_path("topics", "Carol", "Carol.md")
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_create",
|
|
path=target,
|
|
metadata={
|
|
"title": "Carol",
|
|
"lifecycle": "evolving",
|
|
"scope": "class",
|
|
"source": "curated",
|
|
"role": "profile",
|
|
"category": "profile",
|
|
"tags": ["person"],
|
|
},
|
|
content="# Carol\n\nKnows [[Alice]].\n",
|
|
))
|
|
assert isinstance(r, dict) and r.get("created") is True, r
|
|
assert "error" not in r, r
|
|
assert Path(target).is_file(), target
|
|
await wait_for_index(ctx.watcher, expected_min=len(ctx.file_store))
|
|
return f"created {Path(target).name}"
|
|
|
|
|
|
async def check_memory_update(ctx: AppContext) -> str:
|
|
carol = ctx.abs_path("topics", "Carol", "Carol.md")
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_update",
|
|
path=carol,
|
|
old_string="Knows [[Alice]].",
|
|
new_string="Knows [[Alice]] and [[Bob]].",
|
|
))
|
|
assert isinstance(r, dict) and r.get("replaced", 0) >= 1, r
|
|
assert "Bob" in Path(carol).read_text(encoding="utf-8"), "edit not on disk"
|
|
return f"body edit applied (replaced={r['replaced']})"
|
|
|
|
|
|
async def check_memory_property_update(ctx: AppContext) -> str:
|
|
carol = ctx.abs_path("topics", "Carol", "Carol.md")
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_property_update", path=carol, key="confidence", value="✅",
|
|
))
|
|
assert isinstance(r, dict) and "error" not in r, r
|
|
assert r.get("key") == "confidence" and r.get("value") == "✅", r
|
|
assert "confidence: ✅" in Path(carol).read_text(encoding="utf-8"), \
|
|
"property write not on disk"
|
|
return "set confidence=✅"
|
|
|
|
|
|
async def check_memory_rename(ctx: AppContext) -> str:
|
|
src = ctx.abs_path("topics", "Carol", "Carol.md")
|
|
dst = ctx.abs_path("topics", "Carol", "Carol-renamed.md")
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_rename", old_path=src, new_path=dst,
|
|
))
|
|
assert isinstance(r, dict) and "error" not in r, r
|
|
assert r.get("new_path") and Path(r["new_path"]).is_file(), r
|
|
assert not Path(src).exists(), f"old path still on disk: {src}"
|
|
setattr(ctx, "_carol_path", r["new_path"])
|
|
return "Carol.md → Carol-renamed.md"
|
|
|
|
|
|
async def check_memory_archive(ctx: AppContext) -> str:
|
|
carol = getattr(ctx, "_carol_path", ctx.abs_path("topics", "Carol", "Carol-renamed.md"))
|
|
r = decode(await ctx.app.run_job("memory_archive", path=carol))
|
|
assert isinstance(r, dict) and r.get("archived") is True, r
|
|
archived_path = r.get("new_path")
|
|
assert archived_path and "Archive" in archived_path, r
|
|
assert Path(archived_path).is_file(), archived_path
|
|
setattr(ctx, "_carol_archived", archived_path)
|
|
return f"→ {Path(archived_path).resolve().relative_to(ctx.vault.resolve())}"
|
|
|
|
|
|
async def check_memory_delete(ctx: AppContext) -> str:
|
|
target = getattr(ctx, "_carol_archived", None) \
|
|
or ctx.abs_path("topics", "Carol", "Carol-renamed.md")
|
|
r = decode(await ctx.app.run_job("memory_delete", path=target))
|
|
assert isinstance(r, dict) and r.get("deleted") is True, r
|
|
assert not Path(target).exists(), target
|
|
return "removed"
|
|
|
|
|
|
# ---------- schema gates (P4) -----------------------------------------
|
|
|
|
|
|
async def check_schema_path_template_refuses(ctx: AppContext) -> str:
|
|
"""memory_create rejects paths outside topics/{X}/{Y}.md,
|
|
events/{date}/{name}/..., or Archive/..."""
|
|
target = ctx.abs_path("notes", "freeform.md") # outside any template
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_create",
|
|
path=target,
|
|
metadata={"title": "freeform",
|
|
"lifecycle": "evolving", "scope": "class",
|
|
"source": "curated", "role": "concept"},
|
|
content="should be refused",
|
|
))
|
|
assert isinstance(r, dict), r
|
|
assert "error" in r and "template" in r["error"].lower(), r
|
|
assert not Path(target).exists(), "file shouldn't have been written"
|
|
return "refused notes/freeform.md (no template)"
|
|
|
|
|
|
async def check_schema_path_template_force_bypass(ctx: AppContext) -> str:
|
|
"""force=True bypasses the template gate."""
|
|
target = ctx.abs_path("notes", "forced.md")
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_create",
|
|
path=target,
|
|
metadata={"title": "forced",
|
|
"lifecycle": "evolving", "scope": "class",
|
|
"source": "curated", "role": "concept"},
|
|
content="forced through",
|
|
force=True,
|
|
))
|
|
assert isinstance(r, dict) and r.get("created") is True, r
|
|
assert Path(target).is_file(), target
|
|
return "force=True bypassed"
|
|
|
|
|
|
async def check_schema_status_skip_refused(ctx: AppContext) -> str:
|
|
"""Suite-event ended distilled (sync.refuse_distilled flipped it).
|
|
Trying distilled → active is reverse — must refuse."""
|
|
event_index = getattr(ctx, "_suite_event_index", None)
|
|
assert event_index is not None, "suite-event index not staged"
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_property_update",
|
|
path=str(event_index),
|
|
key="status",
|
|
value="active",
|
|
))
|
|
assert isinstance(r, dict), r
|
|
assert "error" in r and "transition" in r["error"].lower(), r
|
|
assert r.get("prior") == "distilled", r
|
|
return f"refused {r.get('prior')!r} → {r.get('requested')!r}"
|
|
|
|
|
|
async def check_schema_status_invalid_value(ctx: AppContext) -> str:
|
|
"""Random string for status is refused before any state-machine check."""
|
|
event_index = getattr(ctx, "_suite_event_index", None)
|
|
assert event_index is not None, "suite-event index not staged"
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_property_update",
|
|
path=str(event_index),
|
|
key="status",
|
|
value="bogus",
|
|
))
|
|
assert isinstance(r, dict), r
|
|
assert "error" in r and "invalid" in r["error"].lower(), r
|
|
return "refused status='bogus'"
|
|
|
|
|
|
async def check_schema_status_force_bypass(ctx: AppContext) -> str:
|
|
"""force=True bypasses the state machine — useful when the agent
|
|
intentionally needs to step outside conventions."""
|
|
event_index = getattr(ctx, "_suite_event_index", None)
|
|
assert event_index is not None, "suite-event index not staged"
|
|
r = decode(await ctx.app.run_job(
|
|
"memory_property_update",
|
|
path=str(event_index),
|
|
key="status",
|
|
value="active",
|
|
force=True,
|
|
))
|
|
assert isinstance(r, dict), r
|
|
assert "error" not in r, r
|
|
# restore for any downstream checks
|
|
decode(await ctx.app.run_job(
|
|
"memory_property_update",
|
|
path=str(event_index), key="status", value="distilled", force=True,
|
|
))
|
|
return "force=True bypassed"
|
|
|
|
|
|
# ---------- ordered manifest -----------------------------------------
|
|
|
|
|
|
# (label, async fn) — runner executes top-to-bottom; later checks may
|
|
# rely on side effects from earlier ones (e.g. sync.append needs
|
|
# sync.create to have run).
|
|
CHECKS: list[tuple[str, callable]] = [
|
|
("registry", check_registry),
|
|
("memory_get", check_memory_get),
|
|
("memory_list", check_memory_list),
|
|
("memory_search", check_memory_search),
|
|
("memory_graph_search", check_memory_graph_search),
|
|
("memory_links", check_memory_links),
|
|
("memory_backlinks", check_memory_backlinks),
|
|
("memory_resolve_wikilink", check_memory_resolve_wikilink),
|
|
("memory_count_tokens", check_memory_count_tokens),
|
|
("memory_lint", check_memory_lint),
|
|
("sync.create", check_sync_create),
|
|
("sync.append", check_sync_append),
|
|
("sync.refuse_distilled", check_sync_refuse_distilled),
|
|
("memory_create", check_memory_create),
|
|
("memory_update", check_memory_update),
|
|
("memory_property_update", check_memory_property_update),
|
|
("memory_rename", check_memory_rename),
|
|
("memory_archive", check_memory_archive),
|
|
("memory_delete", check_memory_delete),
|
|
("schema.path_template_refuses", check_schema_path_template_refuses),
|
|
("schema.path_template_force", check_schema_path_template_force_bypass),
|
|
("schema.status_skip_refused", check_schema_status_skip_refused),
|
|
("schema.status_invalid_value", check_schema_status_invalid_value),
|
|
("schema.status_force", check_schema_status_force_bypass),
|
|
]
|