slice 10.4: wire 12 write_tools into MCP registry (12/12 dispatch tests)
- mcp_tools.TOOL_REGISTRY goes 24 → 36 entries (12 new write tools) - Exposes: add_entity, add_relation, add_lore_source (slice 4.7 trio that had been callable from scripts/02_demo.py only), plus set_alias, update_entity, delete_entity (10.1), retcon, mark_verified, merge_entities (10.2), define_calendar, define_era, define_date (10.3) - Hand-written JSON Schema per tool; trailing-underscore wire fields (name_, object_) match the Python kwarg convention used by the underlying functions - test_tool_registry.py: EXPECTED_TOOLS / EXPECTED_FN grown to 36 entries; the schema-vs-signature drift detector (already in place) validates the trailing-underscore convention - test_protocol.py: tools/list count 24 → 36 - test_slice10_dispatch.py: 12 new dispatch tests, one per new tool; retcon / mark_verified verify envelope shape only because edge_id doesn't survive a subprocess restart (in-memory graph) — actual mutation behaviour is covered in test_write_tools_slice10b.py - Suite 529/529 green (was 517; +12) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -51,6 +51,20 @@ from .read_tools import (
|
||||
true_during,
|
||||
)
|
||||
from .tools import Graph, was_true_at
|
||||
from .write_tools import (
|
||||
add_entity,
|
||||
add_lore_source,
|
||||
add_relation,
|
||||
define_calendar,
|
||||
define_date,
|
||||
define_era,
|
||||
delete_entity,
|
||||
mark_verified,
|
||||
merge_entities,
|
||||
retcon,
|
||||
set_alias,
|
||||
update_entity,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -697,6 +711,427 @@ TOOL_REGISTRY: list[ToolEntry] = [
|
||||
graph_is_used=True,
|
||||
opt_string_params=("type_",),
|
||||
),
|
||||
# --- Slice 10 — 12 write tools exposed over MCP. The
|
||||
# slice-4.7 trio (add_entity / add_relation / add_lore_source)
|
||||
# was already implemented in write_tools.py but had not been
|
||||
# exposed over the wire (the slice 9 doc noted they were
|
||||
# callable from scripts/02_demo.py only). Slice 10.4 wires
|
||||
# all 12 write tools into the MCP registry; the underlying
|
||||
# functions and their unit tests are unchanged.
|
||||
_entry(
|
||||
name="add_entity",
|
||||
description=(
|
||||
"Create a new entity (node) in the graph. Returns "
|
||||
"{id, name, label}. The label must be in the "
|
||||
"ALLOWED_LABELS set (~36 labels from the ontology). "
|
||||
"Use this when the LLM wants to register a new "
|
||||
"Person, Faction, Location, etc. before adding "
|
||||
"relations that point at it."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Entity label (e.g. 'Person').",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Canonical entity name.",
|
||||
},
|
||||
"properties": {
|
||||
"type": ["object", "null"],
|
||||
"description": (
|
||||
"Optional metadata payload. Slice 4.7 "
|
||||
"stores only name+label; the payload is "
|
||||
"forwarded for slice-5 TypeTemplate use."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["label", "name"],
|
||||
},
|
||||
fn=add_entity,
|
||||
graph_is_used=True,
|
||||
),
|
||||
_entry(
|
||||
name="add_relation",
|
||||
description=(
|
||||
"Append a time-bounded edge to the graph. Returns "
|
||||
"{edge_id, subject, relation, object, valid_from, "
|
||||
"valid_until}. The relation is free-form (no "
|
||||
"edge-type allowlist in slice 4.7). Use valid_from / "
|
||||
"valid_until to time-bound the relation; omit both "
|
||||
"for an open-ended assertion."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from_name": {
|
||||
"type": "string",
|
||||
"description": "Subject (source) entity name.",
|
||||
},
|
||||
"relation": {
|
||||
"type": "string",
|
||||
"description": "Relation type (e.g. 'MEMBER_OF').",
|
||||
},
|
||||
"to_name": {
|
||||
"type": "string",
|
||||
"description": "Object (target) entity name.",
|
||||
},
|
||||
"valid_from": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Inclusive lower time bound.",
|
||||
},
|
||||
"valid_until": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Inclusive upper time bound.",
|
||||
},
|
||||
},
|
||||
"required": ["from_name", "relation", "to_name"],
|
||||
},
|
||||
fn=add_relation,
|
||||
graph_is_used=True,
|
||||
opt_string_params=("valid_from", "valid_until"),
|
||||
),
|
||||
_entry(
|
||||
name="add_lore_source",
|
||||
description=(
|
||||
"Register a LoreSource (markdown / YAML) in the graph. "
|
||||
"Does NOT chunk or embed the content; that's the LLM "
|
||||
"extraction path (slice 3). Returns {id, title, "
|
||||
"source_type, author, reliability}."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Source title (canonical display name).",
|
||||
},
|
||||
"source_type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"One of 'prose', 'timeline', 'family_tree', "
|
||||
"'gazetteer', 'bestiary', 'culture', "
|
||||
"'magic_system', 'dialogue'."
|
||||
),
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Raw source content (not chunked).",
|
||||
},
|
||||
"author": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional author name.",
|
||||
},
|
||||
},
|
||||
"required": ["title", "source_type"],
|
||||
},
|
||||
fn=add_lore_source,
|
||||
graph_is_used=True,
|
||||
opt_string_params=("author",),
|
||||
),
|
||||
_entry(
|
||||
name="set_alias",
|
||||
description=(
|
||||
"Register an alternative name (alias) for an entity. "
|
||||
"Returns {name, alias}. After this, lookups by the "
|
||||
"alias resolve to the canonical name."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Canonical entity name.",
|
||||
},
|
||||
"alias": {
|
||||
"type": "string",
|
||||
"description": "The alias to register.",
|
||||
},
|
||||
},
|
||||
"required": ["name", "alias"],
|
||||
},
|
||||
fn=set_alias,
|
||||
graph_is_used=True,
|
||||
),
|
||||
_entry(
|
||||
name="update_entity",
|
||||
description=(
|
||||
"Update an entity's type label and/or rename it. "
|
||||
"Rename cascades to every edge pointing at the old "
|
||||
"name. Returns {name, label, edges_repointed}. The "
|
||||
"old name is preserved as an alias of the new one."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Current canonical name.",
|
||||
},
|
||||
"label": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional new type label.",
|
||||
},
|
||||
"name_": {
|
||||
"type": ["string", "null"],
|
||||
"description": (
|
||||
"Optional new canonical name. The trailing "
|
||||
"underscore avoids colliding with the "
|
||||
"positional 'name' argument."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
fn=update_entity,
|
||||
graph_is_used=True,
|
||||
opt_string_params=("label",),
|
||||
),
|
||||
_entry(
|
||||
name="delete_entity",
|
||||
description=(
|
||||
"Remove an entity and every edge that touches it. "
|
||||
"Returns {name, edges_removed} so the caller can audit "
|
||||
"the blast radius. Aliases are dropped too."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Canonical entity name to remove.",
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
fn=delete_entity,
|
||||
graph_is_used=True,
|
||||
),
|
||||
_entry(
|
||||
name="retcon",
|
||||
description=(
|
||||
"Amend an existing edge's time bounds, relation, or "
|
||||
"object. Targets the edge by its stable edge_id "
|
||||
"(returned by add_relation). Stamps retcon_at / "
|
||||
"retcon_note on the edge for the audit log. Returns "
|
||||
"{edge_id, subject, relation, object, valid_from, "
|
||||
"valid_until, retcon_at, retcon_note}."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"edge_id": {
|
||||
"type": "string",
|
||||
"description": "Stable id of the edge to amend.",
|
||||
},
|
||||
"valid_from": {
|
||||
"type": ["string", "null"],
|
||||
"description": "New lower time bound (or null/omit to keep).",
|
||||
},
|
||||
"valid_until": {
|
||||
"type": ["string", "null"],
|
||||
"description": "New upper time bound (or null/omit to keep).",
|
||||
},
|
||||
"relation": {
|
||||
"type": ["string", "null"],
|
||||
"description": "New relation type (or null/omit to keep).",
|
||||
},
|
||||
"object_": {
|
||||
"type": ["string", "null"],
|
||||
"description": (
|
||||
"New object name (or null/omit to keep). "
|
||||
"The trailing underscore avoids the Python "
|
||||
"builtin."
|
||||
),
|
||||
},
|
||||
"note": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Free-text reason for the retcon.",
|
||||
},
|
||||
},
|
||||
"required": ["edge_id"],
|
||||
},
|
||||
fn=retcon,
|
||||
graph_is_used=True,
|
||||
opt_string_params=("valid_from", "valid_until", "relation", "object_", "note"),
|
||||
),
|
||||
_entry(
|
||||
name="mark_verified",
|
||||
description=(
|
||||
"Record a human verification of an edge. Appends a "
|
||||
"(1.0, 1.0, 'human_verified') source tuple so the "
|
||||
"aggregate confidence floors to 1.0. Doesn't touch "
|
||||
"time bounds. Returns {edge_id, verified_by, "
|
||||
"verified_at, verified_note}."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"edge_id": {
|
||||
"type": "string",
|
||||
"description": "Stable id of the edge to verify.",
|
||||
},
|
||||
"verifier": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Verifier's name (email or handle). "
|
||||
"Required, non-empty."
|
||||
),
|
||||
},
|
||||
"note": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Free-text verification note.",
|
||||
},
|
||||
},
|
||||
"required": ["edge_id", "verifier"],
|
||||
},
|
||||
fn=mark_verified,
|
||||
graph_is_used=True,
|
||||
opt_string_params=("note",),
|
||||
),
|
||||
_entry(
|
||||
name="merge_entities",
|
||||
description=(
|
||||
"Fold one canonical name into another. All edges "
|
||||
"pointing at 'from_name' are re-pointed to 'to_name'; "
|
||||
"the from_name is preserved as an alias. Refuses if "
|
||||
"the two have different labels. Returns {merged, "
|
||||
"into, edges_repointed}."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from_name": {
|
||||
"type": "string",
|
||||
"description": "Source canonical name (the one being folded).",
|
||||
},
|
||||
"to_name": {
|
||||
"type": "string",
|
||||
"description": "Target canonical name (the survivor).",
|
||||
},
|
||||
},
|
||||
"required": ["from_name", "to_name"],
|
||||
},
|
||||
fn=merge_entities,
|
||||
graph_is_used=True,
|
||||
),
|
||||
_entry(
|
||||
name="define_calendar",
|
||||
description=(
|
||||
"Create a Calendar node. Returns {name, days_per_year, "
|
||||
"months, description}. Use as the prerequisite for "
|
||||
"define_era and define_date."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Calendar name (e.g. 'Imperial Calendar').",
|
||||
},
|
||||
"days_per_year": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Optional number of days per year.",
|
||||
},
|
||||
"months": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Optional number of months.",
|
||||
},
|
||||
"description": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Free-text description.",
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
fn=define_calendar,
|
||||
graph_is_used=True,
|
||||
opt_string_params=("description",),
|
||||
),
|
||||
_entry(
|
||||
name="define_era",
|
||||
description=(
|
||||
"Create an Era node, link it to a calendar via "
|
||||
"PART_OF, and (when applicable) link to the most "
|
||||
"recent prior era in the same calendar via PRECEDED. "
|
||||
"Returns {name, calendar, start, end, description}."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Era name (also the time-atom prefix).",
|
||||
},
|
||||
"calendar": {
|
||||
"type": "string",
|
||||
"description": "Calendar this era belongs to (must exist).",
|
||||
},
|
||||
"start": {
|
||||
"type": "string",
|
||||
"description": "Start time atom (e.g. '3rd_age.year_0').",
|
||||
},
|
||||
"end": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional end time atom.",
|
||||
},
|
||||
"description": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Free-text description.",
|
||||
},
|
||||
},
|
||||
"required": ["name", "calendar", "start"],
|
||||
},
|
||||
fn=define_era,
|
||||
graph_is_used=True,
|
||||
opt_string_params=("end", "description"),
|
||||
),
|
||||
_entry(
|
||||
name="define_date",
|
||||
description=(
|
||||
"Create a Date node for a point in a calendar. The "
|
||||
"canonical time atom is '{era}.year_{Y}[.month_{M}"
|
||||
"[.day_{D}]]'. Stamps INSTANCE_OF Calendar and, if "
|
||||
"an era is given, DURING Era. Idempotent: calling "
|
||||
"twice with the same args is a no-op. Returns "
|
||||
"{canonical, calendar, year, month, day, era, label}."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"calendar": {
|
||||
"type": "string",
|
||||
"description": "Calendar the date belongs to (must exist).",
|
||||
},
|
||||
"year": {
|
||||
"type": "integer",
|
||||
"description": "Year (>= 1 when an era is given).",
|
||||
},
|
||||
"month": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Optional month (1..12).",
|
||||
},
|
||||
"day": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Optional day (1..31).",
|
||||
},
|
||||
"era": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional era (must exist; prefixes the canonical atom).",
|
||||
},
|
||||
"label": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional display label for the date.",
|
||||
},
|
||||
},
|
||||
"required": ["calendar", "year"],
|
||||
},
|
||||
fn=define_date,
|
||||
graph_is_used=True,
|
||||
opt_string_params=("era", "label"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -109,12 +109,12 @@ def test_initialize_returns_protocol_version_2024_11_05():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tools_list_returns_24_tools():
|
||||
def test_tools_list_returns_36_tools():
|
||||
[resp] = _run_server(
|
||||
[json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})]
|
||||
)
|
||||
tools = resp["result"]["tools"]
|
||||
assert len(tools) == 24
|
||||
assert len(tools) == 36
|
||||
names = {t["name"] for t in tools}
|
||||
assert names == {
|
||||
"was_true_at",
|
||||
@@ -142,6 +142,19 @@ def test_tools_list_returns_24_tools():
|
||||
"event_chain",
|
||||
"events_during",
|
||||
"lore_about",
|
||||
# --- Slice 10.4 — 12 write tools exposed over MCP ---
|
||||
"add_entity",
|
||||
"add_relation",
|
||||
"add_lore_source",
|
||||
"set_alias",
|
||||
"update_entity",
|
||||
"delete_entity",
|
||||
"retcon",
|
||||
"mark_verified",
|
||||
"merge_entities",
|
||||
"define_calendar",
|
||||
"define_era",
|
||||
"define_date",
|
||||
}
|
||||
for t in tools:
|
||||
assert "description" in t
|
||||
@@ -416,8 +429,8 @@ def test_multiple_requests_on_one_connection():
|
||||
assert [r["id"] for r in responses] == [1, 2, 3]
|
||||
# Initialize → protocolVersion set
|
||||
assert responses[0]["result"]["protocolVersion"] == "2024-11-05"
|
||||
# tools/list → 24 tools
|
||||
assert len(responses[1]["result"]["tools"]) == 24
|
||||
# tools/list → 36 tools
|
||||
assert len(responses[1]["result"]["tools"]) == 36
|
||||
# list_ontology_rules → list (not isError)
|
||||
payload = json.loads(responses[2]["result"]["content"][0]["text"])
|
||||
assert isinstance(payload, list)
|
||||
367
tests/test_mcp/test_slice10_dispatch.py
Normal file
367
tests/test_mcp/test_slice10_dispatch.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""End-to-end MCP dispatch tests for the 12 slice-10.4 write tools.
|
||||
|
||||
The 24 original MCP tools (12 from slice 2.6+ and 12 read tools
|
||||
from slice 9) are exercised in ``test_protocol.py`` /
|
||||
``test_slice9_dispatch.py``. This file covers the 12 write
|
||||
tools that were wired into the MCP registry in slice 10.4:
|
||||
each one is invoked through ``scripts/05_mcp_server.py`` (a
|
||||
real subprocess) and the response payload is checked for
|
||||
shape and correctness.
|
||||
|
||||
We use the cached seed codex (``.graph.pkl``) as the fixture,
|
||||
the same as ``test_protocol.py``. Every test is a
|
||||
``tools/call`` request against one of the new tool names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "05_mcp_server.py"
|
||||
GRAPH_PATH = ROOT / "lore_engine_poc" / ".graph.pkl"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subprocess helper (mirrors test_protocol.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_server(stdin_lines: list[str], timeout: float = 30.0) -> list[dict]:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(SCRIPT)],
|
||||
cwd=str(ROOT),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env={**os.environ, "LORE_GRAPH_PATH": str(GRAPH_PATH)},
|
||||
)
|
||||
try:
|
||||
payload = "\n".join(stdin_lines) + "\n"
|
||||
stdout, stderr = proc.communicate(payload, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
raise
|
||||
lines = [line for line in stdout.splitlines() if line.strip()]
|
||||
return [json.loads(line) for line in lines]
|
||||
|
||||
|
||||
def _call(tool: str, arguments: dict, id_: int = 1) -> dict:
|
||||
[resp] = _run_server(
|
||||
[
|
||||
json.dumps(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id_,
|
||||
"method": "tools/call",
|
||||
"params": {"name": tool, "arguments": arguments},
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
assert resp["id"] == id_
|
||||
assert "result" in resp, f"missing result: {resp!r}"
|
||||
return resp["result"]
|
||||
|
||||
|
||||
def _call_sequence(requests: list[dict]) -> list[dict]:
|
||||
"""Send a sequence of requests through a single subprocess and
|
||||
collect every response. Slice-10.4 write tools mutate the
|
||||
in-memory graph; the next call in the same subprocess sees
|
||||
the mutation. (Persistence to disk is not in scope for slice
|
||||
4.7 / 10.)
|
||||
|
||||
Returns the **raw MCP response envelopes** (with id /
|
||||
jsonrpc / result). Use ``r["result"]`` to get the same
|
||||
shape :func:`_call` returns, then :func:`_payload` to decode
|
||||
the JSON payload."""
|
||||
stdin_lines = []
|
||||
for i, (tool, args) in enumerate(requests, start=1):
|
||||
stdin_lines.append(json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"id": i,
|
||||
"method": "tools/call",
|
||||
"params": {"name": tool, "arguments": args},
|
||||
}))
|
||||
responses = _run_server(stdin_lines)
|
||||
assert len(responses) == len(requests), (
|
||||
f"expected {len(requests)} responses, got {len(responses)}: {responses!r}"
|
||||
)
|
||||
return responses
|
||||
|
||||
|
||||
def _payload(result: dict) -> dict:
|
||||
"""Decode the MCP ``content[0].text`` JSON payload."""
|
||||
return json.loads(result["content"][0]["text"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _ensure_graph():
|
||||
"""Make sure .graph.pkl exists for the whole module."""
|
||||
if not GRAPH_PATH.exists():
|
||||
pytest.skip(f"graph cache missing: {GRAPH_PATH}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slice 4.7 trio — newly exposed over MCP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_entity_dispatches():
|
||||
r = _call("add_entity", {"label": "Person", "name": "Slice10Test1"})
|
||||
p = _payload(r)
|
||||
assert p["ok"] is True
|
||||
assert p["data"]["name"] == "Slice10Test1"
|
||||
assert p["data"]["label"] == "Person"
|
||||
|
||||
|
||||
def test_add_relation_dispatches():
|
||||
"""Add an entity, a relation, then verify the edge_id is
|
||||
exposed in the response so retcon / mark_verified can target
|
||||
it. All in a single subprocess so the entity persists."""
|
||||
responses = _call_sequence([
|
||||
("add_entity", {"label": "Person", "name": "Slice10TestSubject"}),
|
||||
("add_entity", {"label": "Faction", "name": "Slice10TestObject"}),
|
||||
("add_relation", {
|
||||
"from_name": "Slice10TestSubject",
|
||||
"relation": "MEMBER_OF",
|
||||
"to_name": "Slice10TestObject",
|
||||
}),
|
||||
])
|
||||
p = _payload(responses[2]["result"])
|
||||
assert p["ok"] is True
|
||||
assert p["data"]["subject"] == "Slice10TestSubject"
|
||||
assert p["data"]["object"] == "Slice10TestObject"
|
||||
assert "edge_id" in p["data"]
|
||||
|
||||
|
||||
def test_add_lore_source_dispatches():
|
||||
r = _call(
|
||||
"add_lore_source",
|
||||
{"title": "Slice10TestSource", "source_type": "prose"},
|
||||
)
|
||||
p = _payload(r)
|
||||
assert p["ok"] is True
|
||||
assert p["data"]["title"] == "Slice10TestSource"
|
||||
assert p["data"]["source_type"] == "prose"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slice 10.1 trio
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_alias_dispatches():
|
||||
responses = _call_sequence([
|
||||
("add_entity", {"label": "Person", "name": "Slice10AliasSubject"}),
|
||||
("set_alias", {
|
||||
"name": "Slice10AliasSubject",
|
||||
"alias": "the TestSubject",
|
||||
}),
|
||||
])
|
||||
p = _payload(responses[1]["result"])
|
||||
assert p["ok"] is True
|
||||
assert p["data"]["alias"] == "the TestSubject"
|
||||
|
||||
|
||||
def test_update_entity_dispatches():
|
||||
"""``name_`` (trailing underscore) is the wire param for
|
||||
rename — the dispatcher passes the JSON field ``name_``
|
||||
through to the Python function as ``name_=`` (the
|
||||
function-level kwarg has a trailing underscore to avoid
|
||||
colliding with the positional ``name`` argument)."""
|
||||
responses = _call_sequence([
|
||||
("add_entity", {"label": "Person", "name": "Slice10UpdateSrc"}),
|
||||
("update_entity", {
|
||||
"name": "Slice10UpdateSrc",
|
||||
"name_": "Slice10UpdateDst",
|
||||
}),
|
||||
])
|
||||
p = _payload(responses[1]["result"])
|
||||
assert p["ok"] is True
|
||||
assert p["data"]["name"] == "Slice10UpdateDst"
|
||||
|
||||
|
||||
def test_delete_entity_dispatches():
|
||||
responses = _call_sequence([
|
||||
("add_entity", {"label": "Person", "name": "Slice10DeleteTarget"}),
|
||||
("delete_entity", {"name": "Slice10DeleteTarget"}),
|
||||
])
|
||||
p = _payload(responses[1]["result"])
|
||||
assert p["ok"] is True
|
||||
assert p["data"]["name"] == "Slice10DeleteTarget"
|
||||
assert p["data"]["edges_removed"] >= 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slice 10.2 trio
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_retcon_dispatches():
|
||||
"""add_entity × 2, add_relation, then retcon — all in a
|
||||
single subprocess so the writes propagate.
|
||||
|
||||
We resolve the edge_id by issuing a small intermediate
|
||||
request (add_entity, add_entity, add_relation), and then
|
||||
a separate _call_sequence that includes the retcon using
|
||||
a placeholder pattern: we use a two-pass approach where
|
||||
the first pass builds the setup and the second pass (in
|
||||
a new subprocess) re-builds and then retcons — but to
|
||||
keep both in one subprocess, we hard-code an edge_id and
|
||||
then assert on shape rather than exact id equality."""
|
||||
responses = _call_sequence([
|
||||
("add_entity", {"label": "Person", "name": "Slice10RetconSubj"}),
|
||||
("add_entity", {"label": "Faction", "name": "Slice10RetconObj"}),
|
||||
("add_relation", {
|
||||
"from_name": "Slice10RetconSubj",
|
||||
"relation": "MEMBER_OF",
|
||||
"to_name": "Slice10RetconObj",
|
||||
}),
|
||||
])
|
||||
eid = _payload(responses[2]["result"])["data"]["edge_id"]
|
||||
# Second subprocess: rebuild setup + retcon in one go.
|
||||
final_responses = _call_sequence([
|
||||
("add_entity", {"label": "Person", "name": "Slice10RetconSubj"}),
|
||||
("add_entity", {"label": "Faction", "name": "Slice10RetconObj"}),
|
||||
("add_relation", {
|
||||
"from_name": "Slice10RetconSubj",
|
||||
"relation": "MEMBER_OF",
|
||||
"to_name": "Slice10RetconObj",
|
||||
}),
|
||||
])
|
||||
eid2 = _payload(final_responses[2]["result"])["data"]["edge_id"]
|
||||
# Now run the retcon in a third subprocess (rebuild + retcon).
|
||||
retcon_responses = _call_sequence([
|
||||
("add_entity", {"label": "Person", "name": "Slice10RetconSubj"}),
|
||||
("add_entity", {"label": "Faction", "name": "Slice10RetconObj"}),
|
||||
("add_relation", {
|
||||
"from_name": "Slice10RetconSubj",
|
||||
"relation": "MEMBER_OF",
|
||||
"to_name": "Slice10RetconObj",
|
||||
}),
|
||||
("retcon", {
|
||||
"edge_id": "__USE_PREVIOUS__", # sentinel; replaced below
|
||||
"note": "updated for slice 10.4 dispatch test",
|
||||
}),
|
||||
])
|
||||
# The sentinel above is wrong (we can't see the prior id).
|
||||
# The retcon test is therefore effectively a smoke test for
|
||||
# the MCP wire — the response will say "edge not found" but
|
||||
# the dispatcher should still return a valid envelope.
|
||||
p = _payload(retcon_responses[3]["result"])
|
||||
# The retcon fails because the edge_id is bogus, but the
|
||||
# envelope is well-formed: we check that the dispatcher
|
||||
# returns ok=False with a not-found error.
|
||||
assert p["ok"] is False
|
||||
assert "not found" in p["error"].lower()
|
||||
|
||||
|
||||
def test_mark_verified_dispatches():
|
||||
"""Same shape as test_retcon_dispatches: build, then assert
|
||||
the wire envelope is well-formed. We can't round-trip the
|
||||
edge_id across subprocesses (each fresh server reloads
|
||||
from .graph.pkl which doesn't have the in-memory write),
|
||||
so we exercise the error path: ok=False, error mentions
|
||||
'not found'."""
|
||||
setup = _call_sequence([
|
||||
("add_entity", {"label": "Person", "name": "Slice10VerifySubj"}),
|
||||
("add_entity", {"label": "Faction", "name": "Slice10VerifyObj"}),
|
||||
("add_relation", {
|
||||
"from_name": "Slice10VerifySubj",
|
||||
"relation": "MEMBER_OF",
|
||||
"to_name": "Slice10VerifyObj",
|
||||
}),
|
||||
])
|
||||
eid = _payload(setup[2]["result"])["data"]["edge_id"]
|
||||
# mark_verified in a fresh subprocess — edge isn't there.
|
||||
final = _call("mark_verified", {"edge_id": eid, "verifier": "x"})
|
||||
p = _payload(final)
|
||||
assert p["ok"] is False
|
||||
assert "not found" in p["error"].lower()
|
||||
|
||||
|
||||
def test_merge_entities_dispatches():
|
||||
responses = _call_sequence([
|
||||
("add_entity", {"label": "Person", "name": "Slice10MergeFrom"}),
|
||||
("add_entity", {"label": "Person", "name": "Slice10MergeTo"}),
|
||||
("merge_entities", {
|
||||
"from_name": "Slice10MergeFrom",
|
||||
"to_name": "Slice10MergeTo",
|
||||
}),
|
||||
])
|
||||
p = _payload(responses[2]["result"])
|
||||
assert p["ok"] is True
|
||||
assert p["data"]["merged"] == "Slice10MergeFrom"
|
||||
assert p["data"]["into"] == "Slice10MergeTo"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slice 10.3 trio
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_define_calendar_dispatches():
|
||||
r = _call(
|
||||
"define_calendar",
|
||||
{"name": "Slice10Calendar", "days_per_year": 360, "months": 12},
|
||||
)
|
||||
p = _payload(r)
|
||||
assert p["ok"] is True
|
||||
assert p["data"]["name"] == "Slice10Calendar"
|
||||
assert p["data"]["days_per_year"] == 360
|
||||
|
||||
|
||||
def test_define_era_dispatches():
|
||||
"""define_calendar and define_era in the same subprocess so
|
||||
the era's calendar reference resolves."""
|
||||
responses = _call_sequence([
|
||||
("define_calendar", {
|
||||
"name": "Slice10Calendar2", "days_per_year": 360, "months": 12,
|
||||
}),
|
||||
("define_era", {
|
||||
"name": "slice10_era_2",
|
||||
"calendar": "Slice10Calendar2",
|
||||
"start": "slice10_era_2.year_0",
|
||||
}),
|
||||
])
|
||||
p = _payload(responses[1]["result"])
|
||||
assert p["ok"] is True
|
||||
assert p["data"]["name"] == "slice10_era_2"
|
||||
assert p["data"]["calendar"] == "Slice10Calendar2"
|
||||
|
||||
|
||||
def test_define_date_dispatches():
|
||||
responses = _call_sequence([
|
||||
("define_calendar", {"name": "Slice10Calendar3"}),
|
||||
("define_era", {
|
||||
"name": "slice10_era_3",
|
||||
"calendar": "Slice10Calendar3",
|
||||
"start": "slice10_era_3.year_0",
|
||||
}),
|
||||
("define_date", {
|
||||
"calendar": "Slice10Calendar3",
|
||||
"year": 100,
|
||||
"era": "slice10_era_3",
|
||||
}),
|
||||
])
|
||||
p = _payload(responses[2]["result"])
|
||||
assert p["ok"] is True
|
||||
assert p["data"]["canonical"] == "slice10_era_3.year_100"
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Tests for the 24-tool MCP registry (slice 2.6.1 + slice 9).
|
||||
"""Tests for the 36-tool MCP registry (slice 2.6.1 + slice 9 + slice 10.4).
|
||||
|
||||
Validates that:
|
||||
|
||||
* The registry exposes exactly the 24 tools we promised
|
||||
(1 from slice 0 + 10 from slice 2 + 13 from slice 4/9).
|
||||
* The registry exposes exactly the 36 tools we promised
|
||||
(1 from slice 0 + 10 from slice 2 + 13 from slice 4/9 + 12
|
||||
from slice 10).
|
||||
* Each entry has a non-empty ``name``, ``description``, and a
|
||||
proper JSON Schema with at least one ``required`` field (or
|
||||
zero args, in which case ``required`` is empty/missing).
|
||||
@@ -35,9 +36,23 @@ from lore_engine_poc.read_tools import (
|
||||
)
|
||||
from lore_engine_poc.tools import was_true_at
|
||||
from lore_engine_poc import consistency_tools
|
||||
from lore_engine_poc.write_tools import (
|
||||
add_entity,
|
||||
add_lore_source,
|
||||
add_relation,
|
||||
define_calendar,
|
||||
define_date,
|
||||
define_era,
|
||||
delete_entity,
|
||||
mark_verified,
|
||||
merge_entities,
|
||||
retcon,
|
||||
set_alias,
|
||||
update_entity,
|
||||
)
|
||||
|
||||
|
||||
# The 24 tool names — locked per the slice-2.6+/slice-9 plan.
|
||||
# The 36 tool names — locked per the slice-2.6+/slice-9/slice-10 plan.
|
||||
EXPECTED_TOOLS = {
|
||||
# Slice 0
|
||||
"was_true_at",
|
||||
@@ -67,6 +82,19 @@ EXPECTED_TOOLS = {
|
||||
"event_chain",
|
||||
"events_during",
|
||||
"lore_about",
|
||||
# Slice 10.4 — 12 write tools exposed over MCP
|
||||
"add_entity",
|
||||
"add_relation",
|
||||
"add_lore_source",
|
||||
"set_alias",
|
||||
"update_entity",
|
||||
"delete_entity",
|
||||
"retcon",
|
||||
"mark_verified",
|
||||
"merge_entities",
|
||||
"define_calendar",
|
||||
"define_era",
|
||||
"define_date",
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +124,19 @@ EXPECTED_FN = {
|
||||
"event_chain": event_chain,
|
||||
"events_during": events_during,
|
||||
"lore_about": lore_about,
|
||||
# Slice 10.4 — 12 write tools
|
||||
"add_entity": add_entity,
|
||||
"add_relation": add_relation,
|
||||
"add_lore_source": add_lore_source,
|
||||
"set_alias": set_alias,
|
||||
"update_entity": update_entity,
|
||||
"delete_entity": delete_entity,
|
||||
"retcon": retcon,
|
||||
"mark_verified": mark_verified,
|
||||
"merge_entities": merge_entities,
|
||||
"define_calendar": define_calendar,
|
||||
"define_era": define_era,
|
||||
"define_date": define_date,
|
||||
}
|
||||
|
||||
|
||||
@@ -104,8 +145,8 @@ EXPECTED_FN = {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_has_exactly_24_tools():
|
||||
assert len(TOOL_REGISTRY) == 24
|
||||
def test_registry_has_exactly_36_tools():
|
||||
assert len(TOOL_REGISTRY) == 36
|
||||
|
||||
|
||||
def test_registry_has_all_expected_names():
|
||||
|
||||
Reference in New Issue
Block a user