From 44722f8e3d00e61cb0a8240ae1c6b250bd944cfb Mon Sep 17 00:00:00 2001 From: CC#3 Kora Runtime Date: Wed, 20 May 2026 04:44:46 -0700 Subject: [PATCH] feat(KR-3 ST1): iso_node_* typed-graph tool family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships 4 model-facing MCP tools that replace Hermes' flat memory.set / memory.append / memory.get with a typed-node API backed by Plan 02's kronicle.agent_scratchpad_entries: * iso_node_create — append a typed node (18 canonical IsoKron entity kinds: Decision / Gotcha / Pattern / Convention / Concept / Ticket / FailedAttempt / AcceptanceTest / GamingPattern / Project / Component / Milestone / CrossCut / ExternalDependency / Resource / Tool / Schema / KronicleBlock). v0.1 packs node_kind into content_inline header so iso_node_read / search recover it without schema changes (dedicated column lands in KR-3a if read patterns firm up). * iso_node_read — point read by entry_id; searches own + cross-agent caches (KR-3a will add a dedicated single-row substrate fetch). * iso_node_search — node_kind filter + free-text substring (FTS in KR-3a) + optional cross_agent_only inclusion + server-side limit cap (max 50 per spec). * iso_node_supersede — append-only revision; inherits node_kind from the original entry; attempts the supersession write + the kora.node.superseded chain event (both deferred behind their existing BUILD_DEVIATIONS entries). Write paths route through scratchpad.write_scratchpad_entry which still defers behind ScratchpadWriteNotAvailableError. The tool handlers catch the defer + return a structured envelope {"ok": False, "deferred": True, "deviation_id": "D-kr2-st3-no-scratchpad-write-mcp-tool", "message": ...} so the model gets an in-band signal rather than an exception. Read paths are fully functional (work today against the existing read surface). Provider wiring: * get_tool_schemas returns the 4 schemas in OpenAI function-call format (matches mem0/honcho/etc. existing memory providers). * handle_tool_call dispatches "iso_node_*" by prefix to the new handler; unknown tool names fall through to the ABC default's clear "Provider isokron does not handle tool X" error. Capability gating (Plan 04 actorHasCapability Python mirror) ships in KR-6 — until then the in-module assert_kora_can_perform helper is a stub that always allows and logs every invocation tagged with the new BUILD_DEVIATIONS entry D-kr3-st1-capability-check-deferred so operators can grep how often the stub is being relied on. Spec § ST1 explicitly pre-authorized this deferral path. Tests (22 new): * Schema validity (well-formed shape, 18-node-kind enum sync, OpenAI function-call format). * Content packing/unpacking round-trip + legacy / null fallback. * Capability check stub logs the deviation ID. * iso_node_create: deferred-write envelope shape; invalid node_kind rejection; invalid scratchpad_kind rejection; missing required fields. * iso_node_read: hits own cache; hits cross-agent cache; returns error on missing entry. * iso_node_search: node_kind filter; case-insensitive text query; server-side limit cap; cross_agent_only inclusion. * iso_node_supersede: inherits original's node_kind; errors when original is missing. * Dispatch: unknown tool raises; provider.handle_tool_call routes iso_node_* by prefix. ST1 skeleton test updated: test_tool_schemas_empty_at_st1 replaced with test_tool_schemas_exposes_iso_node_family (the family is the new tool surface). Local gates: * ty check — 7,337 diagnostics, zero-delta vs KR-2 ST4 baseline. * pytest tests/plugins/memory/ — 270/270 passing (22 new ST1 + 248 pre-ST1). * Full suite via xdist (-n auto): 24,535 / 157 fail / 43 err / 129 skip. xdist isolation noise in tests/tools/* (kanban + others pass in isolation — same family as ST2-ST4 documented noise); none touch plugins/memory/isokron/. Co-Authored-By: Claude Opus 4.7 (1M context) --- BUILD_DEVIATIONS.md | 25 + plugins/memory/isokron/provider.py | 27 +- plugins/memory/isokron/tools/__init__.py | 52 ++ plugins/memory/isokron/tools/iso_node.py | 645 ++++++++++++++++++ tests/plugins/memory/test_iso_node_tools.py | 442 ++++++++++++ .../memory/test_isokron_provider_skeleton.py | 16 +- 6 files changed, 1193 insertions(+), 14 deletions(-) create mode 100644 plugins/memory/isokron/tools/__init__.py create mode 100644 plugins/memory/isokron/tools/iso_node.py create mode 100644 tests/plugins/memory/test_iso_node_tools.py diff --git a/BUILD_DEVIATIONS.md b/BUILD_DEVIATIONS.md index ff9c9ae3041a..81fae4f65e4c 100644 --- a/BUILD_DEVIATIONS.md +++ b/BUILD_DEVIATIONS.md @@ -17,6 +17,31 @@ Format: ## Open +### D-kr3-st1-capability-check-deferred + +- **Bucket**: KR-3 ST1 (`iso_node_*` tool family) +- **Why**: Each `iso_node_*` tool handler is supposed to gate its + invocation through a Python mirror of the TS-side + `assertKoraCanPerform(actor_kind, capability)` (Plan 04 helper at + `packages/sea-mcp-server/src/capability-matrix.ts:657`). That + Python mirror ships in KR-6 as part of the Constitution pre-screen + middleware. Spec § ST1 § "Capability check" explicitly pre-authorizes + this deferral: "if the Python helper isn't ready, BUILD_DEVIATIONS + + use a stub that always allows (with verbatim Rule-6 log + 'BUILD_DEVIATIONS D-kr3-st1-capability-check-deferred — wires in KR-6')". +- **Closes when**: KR-6 ships the Python mirror — at that point + `tools/iso_node.py:assert_kora_can_perform` body switches from + "no-op + log" to the real check, and the per-tool capability map + `_TOOL_CAPABILITIES` becomes the gating source of truth. +- **Guarded by**: + - `plugins/memory/isokron/tools/iso_node.py` — + `assert_kora_can_perform` logs a WARNING tagged with the + deviation ID on every call so operators can grep how often + the stub is being relied on. + - `tests/plugins/memory/test_iso_node_tools.py` — + `test_assert_kora_can_perform_stub_logs_deviation_id` asserts + the log line carries the deviation ID + the capability name. + ### D-kr2-st4-no-chain-emit-mcp-tool - **Bucket**: KR-2 ST4 (chain event emission + recent events read + finalize) diff --git a/plugins/memory/isokron/provider.py b/plugins/memory/isokron/provider.py index 9b63626524bc..dcabac2c8bc1 100644 --- a/plugins/memory/isokron/provider.py +++ b/plugins/memory/isokron/provider.py @@ -255,12 +255,14 @@ def shutdown(self) -> None: # -- Static metadata -------------------------------------------------- def get_tool_schemas(self) -> List[Dict[str, Any]]: - """Return tool schemas (ABC-required). + """Return the model-facing tool schemas this provider exposes. - ST1: return empty list — no tools surfaced until KR-3 wires the - ``iso_node_*`` / ``iso_link_*`` family on top of this provider. + KR-3 ST1 ships the ``iso_node_*`` family (4 tools). ST2 adds + ``iso_link_*`` (3 tools). ST3 wires registration polish. """ - return [] + from .tools import ISO_NODE_TOOL_SCHEMAS + + return list(ISO_NODE_TOOL_SCHEMAS) def get_config_schema(self) -> List[Dict[str, Any]]: return ISOKRON_CONFIG_SCHEMA @@ -513,15 +515,18 @@ def handle_tool_call( args: Dict[str, Any], **kwargs: Any, ) -> str: - """Handle a tool call routed by name. + """Route a tool call to the right typed-graph handler. - The provider returns no tools from ``get_tool_schemas`` (the - ``iso_node_*`` / ``iso_link_*`` family lands in KR-3), so this - hook should never be invoked in normal operation. Inherit the - ABC's "provider X does not handle tool Y" error so a routing - bug surfaces with a clear actionable message. + KR-3 ST1 dispatches ``iso_node_*``. ST2 adds ``iso_link_*``. + Unknown tool names fall through to the ABC default which + raises a clear "Provider isokron does not handle tool X" error. """ - return super().handle_tool_call(tool_name, args, **kwargs) + del kwargs + if tool_name.startswith("iso_node_"): + from .tools import handle_iso_node_tool_call + + return handle_iso_node_tool_call(self, tool_name, args) + return super().handle_tool_call(tool_name, args) # -- Scratchpad reads (sync wrappers around the async reads) ----------- diff --git a/plugins/memory/isokron/tools/__init__.py b/plugins/memory/isokron/tools/__init__.py new file mode 100644 index 000000000000..387393ce40ad --- /dev/null +++ b/plugins/memory/isokron/tools/__init__.py @@ -0,0 +1,52 @@ +"""Beads-pattern typed-graph tools for Kora's memory surface. + +KR-3 ships model-facing MCP tools that replace Hermes' flat ``memory`` +tool surface with typed-node + typed-edge operations against the +IsoKron substrate. + +- **ST1 (this PR)** — ``iso_node_*`` family (4 tools) against + ``kronicle.agent_scratchpad_entries`` (Plan 02 typed scratchpad). +- **ST2** — ``iso_link_*`` family (3 tools) against the RelationLink + substrate (ADR-0033/0034). +- **ST3** — registration polish + Hermes flat memory deprecation + + system prompt updates. + +Spec § 32 calls out a v0.1 simplification: ``iso_node_*`` reads + writes +operate on the scratchpad surface only. Full read-across the 17 entity +tables lands in KR-3a or later when read patterns + permissions firm up. + +Tools that need writes go through the Sea MCP tool surface (cap_-gated ++ chain-audited). As of substrate main, two write tools are missing: + +- ``kora__write_agent_scratchpad`` — tracked as + D-kr2-st3-no-scratchpad-write-mcp-tool. Affects ``iso_node_create`` + and ``iso_node_supersede``. +- ``kora__append_event`` — tracked as + D-kr2-st4-no-chain-emit-mcp-tool. Affects ``iso_node_supersede``'s + ``kora.node.superseded`` event. + +The handlers attempt the writes through the deferred surfaces and +surface a structured ``{"deferred": true, ...}`` payload back to the +model so it can adapt (e.g. skip a follow-up write that would have +keyed off the new entry_id). When the substrate tools ship, the +handlers' bodies stay the same — only ``scratchpad.write_scratchpad_entry`` +and ``events.emit_kora_event`` change. + +Capability gating (Plan 04 ``actorHasCapability`` Python mirror) ships +in KR-6 — until then ``assert_kora_can_perform`` is a stub that always +allows and logs ``D-kr3-st1-capability-check-deferred``. +""" + +from .iso_node import ( + ISO_NODE_TOOL_SCHEMAS, + NODE_KINDS, + assert_kora_can_perform, + handle_iso_node_tool_call, +) + +__all__ = [ + "ISO_NODE_TOOL_SCHEMAS", + "NODE_KINDS", + "assert_kora_can_perform", + "handle_iso_node_tool_call", +] diff --git a/plugins/memory/isokron/tools/iso_node.py b/plugins/memory/isokron/tools/iso_node.py new file mode 100644 index 000000000000..e5a752f241c6 --- /dev/null +++ b/plugins/memory/isokron/tools/iso_node.py @@ -0,0 +1,645 @@ +"""``iso_node_*`` typed-graph tool family (KR-3 ST1). + +Four model-facing MCP tools that replace Hermes' flat ``memory.set`` / +``memory.append`` / ``memory.get`` surface with a typed-node API +backed by ``kronicle.agent_scratchpad_entries`` (Plan 02). + +Each tool handler: + +1. Resolves ``workspace_id`` via the provider's existing + ``_resolve_workspace_id`` precedence. +2. Asserts the relevant ``cap_*`` (stub for now — see + :func:`assert_kora_can_perform`). +3. Performs the substrate operation (read direct, write via the + deferred MCP surface). +4. Returns a JSON-serializable result dict that the MemoryManager + serializes for the model. + +Returns are always shaped ``{"ok": bool, ...}`` so the model can +program against a stable envelope; deferred writes return +``{"ok": False, "deferred": true, "deviation_id": "D-kr2-st3-..."}``. + +# Node kind encoding (v0.1) + +The substrate's ``kronicle.agent_scratchpad_entries.scratchpad_kind`` +column is a closed enum of 7 values (foundation/0135). Beads-pattern +node_kind is a separate, broader concept (18 canonical IsoKron entity +kinds). v0.1 packs the node_kind into the ``content_inline`` header so +``iso_node_search`` and ``iso_node_read`` can recover it without +schema changes. KR-3a or later promotes this to a dedicated column once +read patterns firm up. +""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any, Dict, List + +from ..scratchpad import ( + ScratchpadEntry, + ScratchpadKind, + ScratchpadWriteNotAvailableError, + VisibilityScope, +) + +if TYPE_CHECKING: # pragma: no cover — avoids import cycle at runtime + from ..provider import IsoKronMemoryProvider + + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Canonical node_kind enum — 17 IsoKron entity kinds + KronicleBlock = 18 +# --------------------------------------------------------------------------- + +NODE_KINDS: tuple[str, ...] = ( + "Decision", + "Gotcha", + "Pattern", + "Convention", + "Concept", + "Ticket", + "FailedAttempt", + "AcceptanceTest", + "GamingPattern", + "Project", + "Component", + "Milestone", + "CrossCut", + "ExternalDependency", + "Resource", + "Tool", + "Schema", + "KronicleBlock", +) + +# Valid values for the substrate's ``scratchpad_kind`` ENUM +# (foundation/0135). Used to validate the optional model arg. +_SCRATCHPAD_KINDS: tuple[str, ...] = tuple(k.value for k in ScratchpadKind) + + +# --------------------------------------------------------------------------- +# Capability check stub +# --------------------------------------------------------------------------- + + +# Maps each iso_node_* tool to the cap_* it would gate. +# Sourced from packages/sea-mcp-server/src/capability-matrix.ts — +# the KR-6 Python mirror of actorHasCapability will use this map. +_TOOL_CAPABILITIES: Dict[str, str] = { + "iso_node_create": "cap_write_agent_scratchpad", + "iso_node_read": "cap_read_precommit_scratchpad", + "iso_node_search": "cap_read_precommit_scratchpad", + "iso_node_supersede": "cap_write_agent_scratchpad", +} + + +def assert_kora_can_perform(capability: str) -> None: + """Capability gate stub — wires in KR-6 (Constitution pre-screen). + + [kora.isokron.todo] BUILD_DEVIATIONS D-kr3-st1-capability-check-deferred + — Python mirror of TS-side ``assertKoraCanPerform`` (Plan 04 + helper) lands in KR-6. Until then this is a no-op that logs every + invocation so operators can grep the deviation ID and see which + surface relied on the deferred check. + """ + logger.warning( + "[kora.isokron.todo] capability check stub allowed '%s' " + "(D-kr3-st1-capability-check-deferred — KR-6 wires the real check)", + capability, + ) + + +# --------------------------------------------------------------------------- +# Tool schemas — OpenAI function-call format (matches mem0/honcho/etc.) +# --------------------------------------------------------------------------- + + +ISO_NODE_CREATE_SCHEMA: Dict[str, Any] = { + "name": "iso_node_create", + "description": ( + "Create a new typed node in your working memory (Plan 02 " + "scratchpad). Use this for decisions, observations, gotchas, " + "patterns, or any structured thought you want to retrieve or " + "hand off to other agents later. Choose node_kind from the 18 " + "canonical IsoKron entity kinds." + ), + "parameters": { + "type": "object", + "properties": { + "node_kind": { + "type": "string", + "enum": list(NODE_KINDS), + "description": "One of the 18 canonical entity kinds.", + }, + "title": { + "type": "string", + "maxLength": 200, + "description": "Short title for the node.", + }, + "content_summary": { + "type": "string", + "maxLength": 1000, + "description": "Main body of the node.", + }, + "cross_agent_dereferenceable": { + "type": "boolean", + "default": False, + "description": ( + "When true, the node is visible to Critic and Oracle. " + "Use for handoffs and shared reasoning trails." + ), + }, + "scratchpad_kind": { + "type": "string", + "enum": list(_SCRATCHPAD_KINDS), + "description": ( + "Optional substrate scratchpad_kind enum value " + "(reasoning_trail / hypothesis / discarded_option / " + "override_rationale / self_critique / route_decision / " + "compacted_summary). Defaults to reasoning_trail." + ), + }, + }, + "required": ["node_kind", "title", "content_summary"], + }, +} + +ISO_NODE_READ_SCHEMA: Dict[str, Any] = { + "name": "iso_node_read", + "description": ( + "Read a typed node from your working memory by its entry_id. " + "Returns full content + metadata + decoded node_kind." + ), + "parameters": { + "type": "object", + "properties": { + "entry_id": { + "type": "string", + "format": "uuid", + "description": "The scratchpad_entry_id UUID.", + }, + }, + "required": ["entry_id"], + }, +} + +ISO_NODE_SEARCH_SCHEMA: Dict[str, Any] = { + "name": "iso_node_search", + "description": ( + "Search your working memory by node_kind and/or free-text. " + "Returns up to 20 most-recent matching nodes by default." + ), + "parameters": { + "type": "object", + "properties": { + "node_kind": { + "type": "string", + "enum": list(NODE_KINDS), + "description": "Filter to nodes of this kind (optional).", + }, + "text_query": { + "type": "string", + "description": ( + "Free-text matched against title + content_summary " + "(case-insensitive substring; FTS lands in KR-3a)." + ), + }, + "limit": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 50, + }, + "cross_agent_only": { + "type": "boolean", + "default": False, + "description": ( + "When true, include cross-agent dereferenceable entries " + "from other actor_kinds (Critic, Oracle, claude_pm)." + ), + }, + }, + }, +} + +ISO_NODE_SUPERSEDE_SCHEMA: Dict[str, Any] = { + "name": "iso_node_supersede", + "description": ( + "Append a revised version of an existing node. The old node is " + "marked superseded (not deleted — IsoKron append-only discipline). " + "Use when you've learned something new about a Decision / Pattern " + "/ Gotcha and need to record the update." + ), + "parameters": { + "type": "object", + "properties": { + "superseded_entry_id": { + "type": "string", + "format": "uuid", + "description": "The scratchpad_entry_id of the node being superseded.", + }, + "new_title": { + "type": "string", + "maxLength": 200, + }, + "new_content_summary": { + "type": "string", + "maxLength": 1000, + }, + "supersession_reason": { + "type": "string", + "description": "Why the supersession happened, in Kora's words.", + }, + }, + "required": [ + "superseded_entry_id", + "new_content_summary", + "supersession_reason", + ], + }, +} + + +ISO_NODE_TOOL_SCHEMAS: List[Dict[str, Any]] = [ + ISO_NODE_CREATE_SCHEMA, + ISO_NODE_READ_SCHEMA, + ISO_NODE_SEARCH_SCHEMA, + ISO_NODE_SUPERSEDE_SCHEMA, +] + + +# --------------------------------------------------------------------------- +# Content encoding — pack node_kind into content_inline for v0.1 +# --------------------------------------------------------------------------- + + +_HEADER_PREFIX = "iso_node v0.1\n" +_NODE_KIND_FIELD = "node_kind: " +_TITLE_FIELD = "title: " +_BODY_DELIM = "\n---\n" + + +def _pack_content(node_kind: str, title: str, content_summary: str) -> str: + """Encode (node_kind, title, content_summary) into ``content_inline``. + + Header is short + deterministic so ``iso_node_read`` / search can + parse it back without bloating storage. The body delimiter + (``\\n---\\n``) lets the model include arbitrary markdown in + ``content_summary`` without breaking the parse. + """ + return ( + _HEADER_PREFIX + + _NODE_KIND_FIELD + + node_kind + + "\n" + + _TITLE_FIELD + + title + + _BODY_DELIM + + content_summary + ) + + +def _unpack_content(content_inline: str | None) -> dict[str, Any]: + """Recover (node_kind, title, body) from packed ``content_inline``. + + Returns a dict with the original fields, plus a fallback ``body`` + for legacy entries (created before iso_node packing) where + everything is dumped into body. + """ + if content_inline is None: + return {"node_kind": None, "title": None, "body": None} + if not content_inline.startswith(_HEADER_PREFIX): + # Legacy / non-iso_node entry — surface the raw text as body. + return {"node_kind": None, "title": None, "body": content_inline} + rest = content_inline[len(_HEADER_PREFIX):] + head, _, body = rest.partition(_BODY_DELIM) + node_kind = None + title = None + for line in head.splitlines(): + if line.startswith(_NODE_KIND_FIELD): + node_kind = line[len(_NODE_KIND_FIELD):] + elif line.startswith(_TITLE_FIELD): + title = line[len(_TITLE_FIELD):] + return {"node_kind": node_kind, "title": title, "body": body} + + +# --------------------------------------------------------------------------- +# Tool handlers — async-wrapped where they need the connection's loop +# --------------------------------------------------------------------------- + + +def _validate_node_kind(node_kind: str) -> None: + if node_kind not in NODE_KINDS: + raise ValueError( + f"iso_node: node_kind must be one of the 18 canonical kinds; " + f"got {node_kind!r}" + ) + + +def _scratchpad_kind_from_arg(arg: Any) -> ScratchpadKind: + if arg is None: + return ScratchpadKind.REASONING_TRAIL + if not isinstance(arg, str): + raise ValueError( + f"iso_node: scratchpad_kind must be a string; got {type(arg).__name__}" + ) + try: + return ScratchpadKind(arg) + except ValueError as exc: + raise ValueError( + f"iso_node: scratchpad_kind must be one of {_SCRATCHPAD_KINDS}; " + f"got {arg!r}" + ) from exc + + +def _entry_to_dict(entry: ScratchpadEntry) -> dict[str, Any]: + """Project a ``ScratchpadEntry`` to the JSON shape iso_node tools return.""" + unpacked = _unpack_content(entry.content_inline) + return { + "entry_id": entry.scratchpad_entry_id, + "actor_kind": entry.actor_kind, + "actor_label": entry.actor_label, + "node_kind": unpacked["node_kind"], + "title": unpacked["title"], + "body": unpacked["body"], + "content_uri": entry.content_uri, + "visibility_scope": entry.visibility_scope.value, + "scratchpad_kind": entry.scratchpad_kind.value, + "created_at": entry.created_at, + } + + +def _handle_iso_node_create( + provider: "IsoKronMemoryProvider", args: Dict[str, Any] +) -> Dict[str, Any]: + node_kind = args.get("node_kind") + title = args.get("title") + content_summary = args.get("content_summary") + if not (isinstance(node_kind, str) and isinstance(title, str) + and isinstance(content_summary, str)): + return { + "ok": False, + "error": "iso_node_create requires node_kind, title, content_summary", + } + try: + _validate_node_kind(node_kind) + scratchpad_kind = _scratchpad_kind_from_arg(args.get("scratchpad_kind")) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + + cross_agent = bool(args.get("cross_agent_dereferenceable", False)) + visibility = ( + VisibilityScope.CROSS_AGENT_DEREFERENCEABLE + if cross_agent + else VisibilityScope.AGENT_PRIVATE + ) + + workspace_id = provider._resolve_workspace_id() + if workspace_id is None: + return { + "ok": False, + "error": "iso_node_create: no workspace_id resolvable", + } + + assert_kora_can_perform("cap_write_agent_scratchpad") + + content = _pack_content(node_kind, title, content_summary) + try: + # Calls into provider._attempt_scratchpad_write would log + swallow; + # we need the deferred error in-band so the model gets a structured + # signal. Use the lower-level path with our own try/except. + from ..scratchpad import write_scratchpad_entry + + assert provider._connection is not None + provider._connection.submit_and_wait( + write_scratchpad_entry( + workspace_id=workspace_id, + scratchpad_kind=scratchpad_kind, + visibility_scope=visibility, + content=content, + mcp_client=None, + ), + timeout=10.0, + ) + except ScratchpadWriteNotAvailableError as exc: + return { + "ok": False, + "deferred": True, + "deviation_id": "D-kr2-st3-no-scratchpad-write-mcp-tool", + "message": str(exc), + } + + # When the substrate tool lands, it returns the new entry_id; KR-N + # follow-on swaps this branch in. For now (unreachable in v0.1 + # because the defer always fires) return a sentinel. + return {"ok": True, "entry_id": ""} + + +def _handle_iso_node_read( + provider: "IsoKronMemoryProvider", args: Dict[str, Any] +) -> Dict[str, Any]: + entry_id = args.get("entry_id") + if not isinstance(entry_id, str) or not entry_id: + return {"ok": False, "error": "iso_node_read: entry_id (string) required"} + + workspace_id = provider._resolve_workspace_id() + if workspace_id is None: + return {"ok": False, "error": "iso_node_read: no workspace_id resolvable"} + + assert_kora_can_perform("cap_read_precommit_scratchpad") + + # v0.1: pull own + cross-agent caches; substring-match on entry_id. + # A dedicated point read lands in KR-3a (single-row fetch by id + # would need a new SQL constant + the matching RLS path). + own = provider.read_own_scratchpad() + cross = provider.read_cross_agent_scratchpad() + for entry in (*own, *cross): + if entry.scratchpad_entry_id == entry_id: + return {"ok": True, "node": _entry_to_dict(entry)} + return {"ok": False, "error": f"iso_node_read: no entry with id {entry_id}"} + + +def _handle_iso_node_search( + provider: "IsoKronMemoryProvider", args: Dict[str, Any] +) -> Dict[str, Any]: + node_kind_filter = args.get("node_kind") + text_query_raw = args.get("text_query") + limit_arg = args.get("limit", 20) + cross_agent_only = bool(args.get("cross_agent_only", False)) + + if node_kind_filter is not None: + try: + _validate_node_kind(node_kind_filter) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + + try: + limit = int(limit_arg) + except (TypeError, ValueError): + return {"ok": False, "error": "iso_node_search: limit must be an integer"} + limit = max(1, min(50, limit)) + + text_query = ( + text_query_raw.lower() if isinstance(text_query_raw, str) else None + ) + + workspace_id = provider._resolve_workspace_id() + if workspace_id is None: + return {"ok": False, "error": "iso_node_search: no workspace_id resolvable"} + + assert_kora_can_perform("cap_read_precommit_scratchpad") + + own = provider.read_own_scratchpad() + candidates: list[ScratchpadEntry] = list(own) + if cross_agent_only: + candidates.extend(provider.read_cross_agent_scratchpad()) + + results: list[dict[str, Any]] = [] + for entry in candidates: + unpacked = _unpack_content(entry.content_inline) + if node_kind_filter and unpacked["node_kind"] != node_kind_filter: + continue + if text_query is not None: + haystack = " ".join( + str(v) for v in (unpacked["title"], unpacked["body"]) if v + ).lower() + if text_query not in haystack: + continue + results.append(_entry_to_dict(entry)) + if len(results) >= limit: + break + + return {"ok": True, "results": results, "count": len(results)} + + +def _handle_iso_node_supersede( + provider: "IsoKronMemoryProvider", args: Dict[str, Any] +) -> Dict[str, Any]: + superseded_entry_id = args.get("superseded_entry_id") + new_content_summary = args.get("new_content_summary") + supersession_reason = args.get("supersession_reason") + new_title = args.get("new_title", "") + + if not ( + isinstance(superseded_entry_id, str) + and isinstance(new_content_summary, str) + and isinstance(supersession_reason, str) + ): + return { + "ok": False, + "error": ( + "iso_node_supersede requires superseded_entry_id, " + "new_content_summary, supersession_reason" + ), + } + + workspace_id = provider._resolve_workspace_id() + if workspace_id is None: + return {"ok": False, "error": "iso_node_supersede: no workspace_id resolvable"} + + assert_kora_can_perform("cap_write_agent_scratchpad") + + # Look up the original to inherit node_kind into the supersession. + original_read = _handle_iso_node_read( + provider, {"entry_id": superseded_entry_id} + ) + if not original_read.get("ok"): + return { + "ok": False, + "error": ( + f"iso_node_supersede: cannot resolve superseded entry " + f"({superseded_entry_id}) — {original_read.get('error')}" + ), + } + original_node = original_read["node"] + inherited_kind = original_node.get("node_kind") or "Concept" + packed = _pack_content( + inherited_kind, + new_title or (original_node.get("title") or ""), + f"{new_content_summary}\n\nSupersedes: {superseded_entry_id}\n" + f"Reason: {supersession_reason}", + ) + + # Attempt the write. Both the scratchpad write AND the + # kora.node.superseded chain event are deferred behind their + # respective BUILD_DEVIATIONS entries. We surface the first defer + # we hit so the model sees one clear deviation_id at a time. + try: + from ..scratchpad import write_scratchpad_entry + + assert provider._connection is not None + provider._connection.submit_and_wait( + write_scratchpad_entry( + workspace_id=workspace_id, + scratchpad_kind=ScratchpadKind.COMPACTED_SUMMARY, + visibility_scope=VisibilityScope.AGENT_PRIVATE, + content=packed, + mcp_client=None, + ), + timeout=10.0, + ) + except ScratchpadWriteNotAvailableError as exc: + return { + "ok": False, + "deferred": True, + "deviation_id": "D-kr2-st3-no-scratchpad-write-mcp-tool", + "message": str(exc), + } + + # Successful write would trigger an emit of kora.node.superseded + # (also deferred per D-kr2-st4). Unreachable in v0.1. + try: + from ..events import emit_kora_event + + assert provider._connection is not None + provider._connection.submit_and_wait( + emit_kora_event( + workspace_id=workspace_id, + event_type="kora.node.superseded", + payload={ + "superseded_entry_id": superseded_entry_id, + "reason": supersession_reason, + }, + ), + timeout=10.0, + ) + except Exception as exc: + logger.warning( + "[kora.isokron] iso_node_supersede emit deferred — %s", exc + ) + return {"ok": True, "entry_id": ""} + + +# --------------------------------------------------------------------------- +# Dispatcher +# --------------------------------------------------------------------------- + + +_HANDLERS = { + "iso_node_create": _handle_iso_node_create, + "iso_node_read": _handle_iso_node_read, + "iso_node_search": _handle_iso_node_search, + "iso_node_supersede": _handle_iso_node_supersede, +} + + +def handle_iso_node_tool_call( + provider: "IsoKronMemoryProvider", + tool_name: str, + args: Dict[str, Any], +) -> str: + """Route a tool call to the right ``iso_node_*`` handler. + + Returns a JSON string per the ``MemoryProvider.handle_tool_call`` + ABC contract. + """ + handler = _HANDLERS.get(tool_name) + if handler is None: + raise NotImplementedError( + f"Provider isokron does not handle tool {tool_name!r}" + ) + result = handler(provider, args) + return json.dumps(result, default=str) diff --git a/tests/plugins/memory/test_iso_node_tools.py b/tests/plugins/memory/test_iso_node_tools.py new file mode 100644 index 000000000000..6370313c230c --- /dev/null +++ b/tests/plugins/memory/test_iso_node_tools.py @@ -0,0 +1,442 @@ +"""KR-3 ST1 — ``iso_node_*`` tool family tests. + +Covers schema validity, dispatch, content packing/unpacking, search +filters, deferred-write semantics for ``iso_node_create`` and +``iso_node_supersede``, and the capability-check stub's logging. + +Provider write paths still defer through +``ScratchpadWriteNotAvailableError`` (BUILD_DEVIATIONS +D-kr2-st3-no-scratchpad-write-mcp-tool); these tests assert the tools +surface the defer as a structured ``{"ok": False, "deferred": true, +"deviation_id": ...}`` payload rather than letting the exception +escape. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any, List + +import pytest + +from plugins.memory.isokron.scratchpad import ( + ScratchpadEntry, + ScratchpadKind, + VisibilityScope, +) +from plugins.memory.isokron.tools import ( + ISO_NODE_TOOL_SCHEMAS, + NODE_KINDS, + assert_kora_can_perform, + handle_iso_node_tool_call, +) +from plugins.memory.isokron.tools.iso_node import ( + _pack_content, + _unpack_content, +) + + +WORKSPACE_ID = "org_test_iso_node" + + +# --------------------------------------------------------------------------- +# Fake provider — owns only the bits the tools touch +# --------------------------------------------------------------------------- + + +class _FakeProviderConnection: + """Replaces IsoKronConnection in tool tests. + + ``submit_and_wait`` runs the coroutine via ``asyncio.run`` so the + deferred-error path surfaces exactly as in production. + """ + + def __init__(self): + self.submitted: list = [] + + def submit_and_wait(self, coro, *, timeout: float = 10.0): + self.submitted.append(coro) + return asyncio.run(coro) + + +def _make_provider( + *, + workspace_id: str = WORKSPACE_ID, + own: List[ScratchpadEntry] | None = None, + cross: List[ScratchpadEntry] | None = None, +): + """Build a provider with stubbed reads + fake connection.""" + from plugins.memory.isokron.provider import IsoKronMemoryProvider + + provider = IsoKronMemoryProvider( + config={ + "isokron_dsn": "postgres://kora:secret@localhost:5432/isokron", + "mcp_endpoint": "stdio://node ./sea-mcp-server.js", + "default_workspace_id": workspace_id, + } + ) + fake_conn = _FakeProviderConnection() + setattr(provider, "_connection", fake_conn) + + # Replace the sync read accessors with stubs returning whatever the + # test wants. The tool handlers go through these methods directly. + setattr(provider, "read_own_scratchpad", lambda **kw: list(own or [])) + setattr( + provider, "read_cross_agent_scratchpad", lambda **kw: list(cross or []) + ) + return provider, fake_conn + + +def _entry( + *, + entry_id: str, + node_kind: str = "Decision", + title: str = "title-x", + body: str = "body-y", + actor_kind: str = "kora", + visibility: VisibilityScope = VisibilityScope.AGENT_PRIVATE, + scratchpad_kind: ScratchpadKind = ScratchpadKind.REASONING_TRAIL, +) -> ScratchpadEntry: + return ScratchpadEntry( + scratchpad_entry_id=entry_id, + actor_kind=actor_kind, + actor_label=actor_kind.capitalize(), + content_inline=_pack_content(node_kind, title, body), + content_uri=None, + content_hash="deadbeef" * 8, + visibility_scope=visibility, + scratchpad_kind=scratchpad_kind, + created_at="2026-05-20T12:00:00Z", + ) + + +# --------------------------------------------------------------------------- +# Schemas + registration +# --------------------------------------------------------------------------- + + +def test_iso_node_schemas_are_well_formed(): + """All 4 tool schemas have name/description/parameters; OpenAI function shape.""" + assert len(ISO_NODE_TOOL_SCHEMAS) == 4 + names = {s["name"] for s in ISO_NODE_TOOL_SCHEMAS} + assert names == { + "iso_node_create", + "iso_node_read", + "iso_node_search", + "iso_node_supersede", + } + for schema in ISO_NODE_TOOL_SCHEMAS: + assert isinstance(schema["name"], str) + assert isinstance(schema["description"], str) + params = schema["parameters"] + assert params["type"] == "object" + assert isinstance(params["properties"], dict) + + +def test_node_kinds_canonical_list_has_18_entries(): + """The 17 IsoKron entity kinds + KronicleBlock = 18.""" + assert len(NODE_KINDS) == 18 + assert "Decision" in NODE_KINDS + assert "KronicleBlock" in NODE_KINDS + + +def test_iso_node_create_schema_enum_matches_node_kinds(): + """The JSON Schema enum is in sync with the Python NODE_KINDS tuple.""" + schema = next(s for s in ISO_NODE_TOOL_SCHEMAS if s["name"] == "iso_node_create") + enum = schema["parameters"]["properties"]["node_kind"]["enum"] + assert tuple(enum) == NODE_KINDS + + +# --------------------------------------------------------------------------- +# Content packing +# --------------------------------------------------------------------------- + + +def test_pack_then_unpack_round_trips_fields(): + packed = _pack_content("Decision", "Pick IsoKron", "We picked it because ...") + unpacked = _unpack_content(packed) + assert unpacked["node_kind"] == "Decision" + assert unpacked["title"] == "Pick IsoKron" + assert unpacked["body"] == "We picked it because ..." + + +def test_unpack_handles_legacy_unpacked_content(): + """Non-iso_node entries surface as body-only (no node_kind / title).""" + unpacked = _unpack_content("just a plain scratchpad row") + assert unpacked["node_kind"] is None + assert unpacked["title"] is None + assert unpacked["body"] == "just a plain scratchpad row" + + +def test_unpack_handles_null_content_inline(): + """``content_uri``-backed entries have content_inline=None.""" + unpacked = _unpack_content(None) + assert unpacked == {"node_kind": None, "title": None, "body": None} + + +# --------------------------------------------------------------------------- +# Capability check stub +# --------------------------------------------------------------------------- + + +def test_assert_kora_can_perform_stub_logs_deviation_id(caplog): + """The stub always allows but logs the deviation ID for grep.""" + with caplog.at_level(logging.WARNING, logger="plugins.memory.isokron.tools.iso_node"): + assert_kora_can_perform("cap_test") + messages = [r.getMessage() for r in caplog.records] + assert any("D-kr3-st1-capability-check-deferred" in m for m in messages) + assert any("cap_test" in m for m in messages) + + +# --------------------------------------------------------------------------- +# iso_node_create +# --------------------------------------------------------------------------- + + +def test_iso_node_create_returns_deferred_payload(caplog): + """The deferred scratchpad write surfaces as a structured JSON envelope.""" + provider, _conn = _make_provider() + with caplog.at_level(logging.WARNING, logger="plugins.memory.isokron"): + result = handle_iso_node_tool_call( + provider, + "iso_node_create", + { + "node_kind": "Decision", + "title": "Test", + "content_summary": "Body", + "cross_agent_dereferenceable": False, + }, + ) + decoded = json.loads(result) + assert decoded["ok"] is False + assert decoded["deferred"] is True + assert decoded["deviation_id"] == "D-kr2-st3-no-scratchpad-write-mcp-tool" + assert "[kora.isokron.todo]" in decoded["message"] + + +def test_iso_node_create_rejects_invalid_node_kind(): + provider, _conn = _make_provider() + result = handle_iso_node_tool_call( + provider, + "iso_node_create", + {"node_kind": "NotARealKind", "title": "t", "content_summary": "s"}, + ) + decoded = json.loads(result) + assert decoded["ok"] is False + assert "node_kind" in decoded["error"] + + +def test_iso_node_create_rejects_invalid_scratchpad_kind(): + provider, _conn = _make_provider() + result = handle_iso_node_tool_call( + provider, + "iso_node_create", + { + "node_kind": "Decision", + "title": "t", + "content_summary": "s", + "scratchpad_kind": "not_a_real_kind", + }, + ) + decoded = json.loads(result) + assert decoded["ok"] is False + assert "scratchpad_kind" in decoded["error"] + + +def test_iso_node_create_missing_required_fields_returns_error(): + provider, _conn = _make_provider() + result = handle_iso_node_tool_call( + provider, "iso_node_create", {"node_kind": "Decision"} + ) + decoded = json.loads(result) + assert decoded["ok"] is False + assert "title" in decoded["error"] or "content_summary" in decoded["error"] + + +# --------------------------------------------------------------------------- +# iso_node_read +# --------------------------------------------------------------------------- + + +def test_iso_node_read_finds_entry_in_own_cache(): + target = _entry(entry_id="aaaa-1", title="Decision X") + provider, _conn = _make_provider(own=[target]) + result = handle_iso_node_tool_call( + provider, "iso_node_read", {"entry_id": "aaaa-1"} + ) + decoded = json.loads(result) + assert decoded["ok"] is True + assert decoded["node"]["entry_id"] == "aaaa-1" + assert decoded["node"]["node_kind"] == "Decision" + assert decoded["node"]["title"] == "Decision X" + + +def test_iso_node_read_finds_entry_in_cross_agent_cache(): + target = _entry( + entry_id="bbbb-2", + actor_kind="critic", + visibility=VisibilityScope.CROSS_AGENT_DEREFERENCEABLE, + ) + provider, _conn = _make_provider(own=[], cross=[target]) + result = handle_iso_node_tool_call( + provider, "iso_node_read", {"entry_id": "bbbb-2"} + ) + decoded = json.loads(result) + assert decoded["ok"] is True + assert decoded["node"]["actor_kind"] == "critic" + + +def test_iso_node_read_returns_error_on_missing_entry(): + provider, _conn = _make_provider(own=[]) + result = handle_iso_node_tool_call( + provider, "iso_node_read", {"entry_id": "does-not-exist"} + ) + decoded = json.loads(result) + assert decoded["ok"] is False + assert "no entry" in decoded["error"] + + +# --------------------------------------------------------------------------- +# iso_node_search +# --------------------------------------------------------------------------- + + +def test_iso_node_search_filters_by_kind(): + entries = [ + _entry(entry_id="1", node_kind="Decision", title="d-1"), + _entry(entry_id="2", node_kind="Gotcha", title="g-1"), + _entry(entry_id="3", node_kind="Decision", title="d-2"), + ] + provider, _conn = _make_provider(own=entries) + result = handle_iso_node_tool_call( + provider, "iso_node_search", {"node_kind": "Decision"} + ) + decoded = json.loads(result) + assert decoded["ok"] is True + assert decoded["count"] == 2 + assert {r["entry_id"] for r in decoded["results"]} == {"1", "3"} + + +def test_iso_node_search_filters_by_text_query_case_insensitive(): + entries = [ + _entry(entry_id="1", title="Migration plan", body="postgres details"), + _entry(entry_id="2", title="Schema notes", body="indexing strategy"), + ] + provider, _conn = _make_provider(own=entries) + result = handle_iso_node_tool_call( + provider, "iso_node_search", {"text_query": "MIGRATION"} + ) + decoded = json.loads(result) + assert decoded["ok"] is True + assert decoded["count"] == 1 + assert decoded["results"][0]["entry_id"] == "1" + + +def test_iso_node_search_respects_limit_cap(): + entries = [_entry(entry_id=str(i)) for i in range(100)] + provider, _conn = _make_provider(own=entries) + # Asks for 200; server-side caps at 50. + result = handle_iso_node_tool_call( + provider, "iso_node_search", {"limit": 200} + ) + decoded = json.loads(result) + assert decoded["count"] == 50 + + +def test_iso_node_search_cross_agent_only_includes_other_actors(): + own_entries = [_entry(entry_id="o1", node_kind="Decision", title="own")] + cross_entries = [ + _entry( + entry_id="c1", + node_kind="Decision", + title="critic-handoff", + actor_kind="critic", + visibility=VisibilityScope.CROSS_AGENT_DEREFERENCEABLE, + ), + ] + provider, _conn = _make_provider(own=own_entries, cross=cross_entries) + + own_only = json.loads( + handle_iso_node_tool_call( + provider, "iso_node_search", {"node_kind": "Decision"} + ) + ) + assert own_only["count"] == 1 + assert own_only["results"][0]["actor_kind"] == "kora" + + with_cross = json.loads( + handle_iso_node_tool_call( + provider, + "iso_node_search", + {"node_kind": "Decision", "cross_agent_only": True}, + ) + ) + assert with_cross["count"] == 2 + assert {r["actor_kind"] for r in with_cross["results"]} == {"kora", "critic"} + + +# --------------------------------------------------------------------------- +# iso_node_supersede +# --------------------------------------------------------------------------- + + +def test_iso_node_supersede_inherits_node_kind_from_original(): + original = _entry(entry_id="orig-1", node_kind="Pattern", title="old-title") + provider, conn = _make_provider(own=[original]) + result = handle_iso_node_tool_call( + provider, + "iso_node_supersede", + { + "superseded_entry_id": "orig-1", + "new_content_summary": "refined understanding", + "supersession_reason": "learned more", + }, + ) + decoded = json.loads(result) + # Write defers like iso_node_create — but it must NOT short-circuit + # before resolving the original (otherwise the new entry would lose + # its inherited node_kind when the substrate tool lands). + assert decoded["ok"] is False + assert decoded["deferred"] is True + assert decoded["deviation_id"] == "D-kr2-st3-no-scratchpad-write-mcp-tool" + # The defer happened during the write attempt — meaning the original + # lookup succeeded + the packing happened (a coroutine was submitted). + assert len(conn.submitted) == 1 + + +def test_iso_node_supersede_errors_when_original_missing(): + provider, _conn = _make_provider(own=[]) + result = handle_iso_node_tool_call( + provider, + "iso_node_supersede", + { + "superseded_entry_id": "ghost-id", + "new_content_summary": "x", + "supersession_reason": "y", + }, + ) + decoded = json.loads(result) + assert decoded["ok"] is False + assert "cannot resolve superseded entry" in decoded["error"] + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + + +def test_handle_iso_node_tool_call_unknown_tool_raises(): + provider, _conn = _make_provider() + with pytest.raises(NotImplementedError) as excinfo: + handle_iso_node_tool_call(provider, "iso_node_not_a_real_tool", {}) + assert "isokron" in str(excinfo.value) + + +def test_provider_handle_tool_call_dispatches_iso_node_prefix(): + """Provider.handle_tool_call routes iso_node_* by prefix.""" + provider, _conn = _make_provider(own=[_entry(entry_id="x1")]) + raw = provider.handle_tool_call("iso_node_read", {"entry_id": "x1"}) + decoded = json.loads(raw) + assert decoded["ok"] is True diff --git a/tests/plugins/memory/test_isokron_provider_skeleton.py b/tests/plugins/memory/test_isokron_provider_skeleton.py index ec59788e88f0..c509a45bff0b 100644 --- a/tests/plugins/memory/test_isokron_provider_skeleton.py +++ b/tests/plugins/memory/test_isokron_provider_skeleton.py @@ -250,12 +250,22 @@ def test_handle_tool_call_unsupported_tool_raises_with_provider_name(): # --------------------------------------------------------------------------- -def test_tool_schemas_empty_at_st1(): - """No iso_node_* / iso_link_* tools surface until KR-3.""" +def test_tool_schemas_exposes_iso_node_family(): + """KR-3 ST1: 4 iso_node_* tools registered; iso_link_* lands in ST2.""" from plugins.memory.isokron.provider import IsoKronMemoryProvider provider = IsoKronMemoryProvider(config=_minimal_config()) - assert provider.get_tool_schemas() == [] + schemas = provider.get_tool_schemas() + names = {s["name"] for s in schemas} + assert names == { + "iso_node_create", + "iso_node_read", + "iso_node_search", + "iso_node_supersede", + } + # OpenAI function-call shape — each schema has name, description, parameters. + for s in schemas: + assert {"name", "description", "parameters"} <= s.keys() def test_config_schema_carries_all_six_fields():