diff --git a/docs/PLAN-nil-intent.md b/docs/PLAN-nil-intent.md new file mode 100644 index 0000000..1990d9c --- /dev/null +++ b/docs/PLAN-nil-intent.md @@ -0,0 +1,51 @@ +# NIL Intent — the single payload + +**Invariant:** the model emits ONLY an `Intent` (a semantic payload of *what* it wants). The system +deterministically resolves → governs → executes → returns an `Outcome`. No tool selection, no filter +construction by the model, no keyword/string matching anywhere, no per-entity special-casing. Universal +at the contract level — every adapter inherits it. + +## The one payload +``` +Intent { + about: // adapter-agnostic; resolved by the graph (or identity) + where: [ Binding{attr, rel, value} ] // rel ∈ is|contains|gt|gte|lt|lte|between|in (structural) + seek: the | all | count | summary // shape of knowing (read) + | change{ op: create|update|remove, set:{attr:value} } // shape of changing (write) + page?: {limit, cursor} +} +⇒ Outcome { result | proposal(preview,tier) | refusal(code, fix) } +``` +The model fills a schema (expresses *what*); the system owns *how*. + +## Deterministic pipeline (resolve_intent — pure code, zero model, zero lexical) +1. resolve `about`/`where` → adapter target + typed filter (BindingResolver + graph; IdentityResolver default). +2. govern: read → free/bulk-gated; change → propose→commit→tier. +3. execute: read → ReadPlane (projection/cap/capability by `seek`); change → write executor. +4. return Outcome (small result / held preview / structured refusal with the corrective parameter). + +## Reuse, not greenfield +`/api/assert` + `resolve_bindings` (graph) · `ReadPlane` (built) · propose/commit + approval-executes +(built) · ontology graph (built). This UNIFIES them under one intent surface and extends it to reads. + +## Surface +One MCP tool `nil_intent(intent | [intent...])`. Legacy verb tools become the internal execution layer +(hidden from the model). Multiple intents = list; system executes 100% of what's permitted (heavy via +the bulk spine), refuses the rest structurally. + +## REL → op (fixed enum map, not keywords) +is→eq · contains→ilike · gt→gt · gte→gte · lt→lt · lte→lte · between→between · in→in + +## Phases (TDD) +1. Intent/Binding/Outcome types + `IntentResolver` for reads (the/all/count) over ReadPlane. ← START +2. summary→aggregate; structured refusals carry the fix. +3. writes: change→propose→commit→tier via the executor. +4. `nil_intent` MCP tool; deprecate verb-selection tools (kept internal). +5. batch intents + partial-allow. +6. Hermes SOUL/skill emits intents only; deploy; verify vague "ابحث عن دينا" on Haiku. + +## Universality +Contract (`Intent`+`resolve_intent`+REL map) in the kernel; BindingResolver pluggable (graph supplies +the ontology mapping, IdentityResolver for adapters without one). Conformance: an intent over any +conformant adapter resolves+executes identically. Governance unchanged (reads bounded/projected/capped, +bulk gated; writes propose→commit→tier). diff --git a/src/nilscript/dataplane/__init__.py b/src/nilscript/dataplane/__init__.py index 20f111d..7f64507 100644 --- a/src/nilscript/dataplane/__init__.py +++ b/src/nilscript/dataplane/__init__.py @@ -142,11 +142,31 @@ def enforce_byte_cap(payload: Any, cap: int = BYTE_CAP) -> Any: NotAuthorizedHandle, ) from .bulk import BulkResult, run_bulk # noqa: E402 +from .intent import ( # noqa: E402 + OP_TO_RESOURCE, + REL_TO_OP, + Binding, + BindingResolver, + Change, + IdentityResolver, + Intent, + IntentResolver, + Outcome, +) __all__ = [ "BYTE_CAP", "BULK_THRESHOLD", + "REL_TO_OP", + "Binding", + "BindingResolver", "BulkApprovalRequired", + "Change", + "OP_TO_RESOURCE", + "IdentityResolver", + "Intent", + "IntentResolver", + "Outcome", "BulkResult", "EDGE_FILTER_BOUND", "run_bulk", diff --git a/src/nilscript/dataplane/intent.py b/src/nilscript/dataplane/intent.py new file mode 100644 index 0000000..d979b6f --- /dev/null +++ b/src/nilscript/dataplane/intent.py @@ -0,0 +1,137 @@ +"""NIL Intent — the single model-facing payload. The model emits an `Intent` (a semantic description of +*what* it wants to know or change); `IntentResolver` deterministically maps it to a governed, lean +execution and returns an `Outcome`. No tool selection, no filter constructed by the model, no keyword +matching — the model fills a schema, the system owns the mechanics. + +This file covers READS (the/all/count). Writes (change→propose→commit→tier) and summary→aggregate plug +into the same resolve() in later phases. The ontology mapping (about→target, attr→field) is delegated to +a pluggable `BindingResolver` — the graph supplies the real one; `IdentityResolver` is the default for +adapters without an ontology layer (about IS the target, attr IS the field). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol + +from .engine import CapabilityUnsupported, ReadPlane +from . import InvalidFilter, ResultTooLarge + +# Structural relation → typed filter op. A FIXED enum map (not keyword matching): the model picks a +# relation from a closed set, the system maps it to the data-plane op. Universal across adapters. +REL_TO_OP: dict[str, str] = { + "is": "eq", "is_not": "ne", "contains": "ilike", + "gt": "gt", "gte": "gte", "lt": "lt", "lte": "lte", "between": "between", "in": "in", +} + +SEEK_SHAPES = frozenset({"the", "all", "count", "summary"}) + + +@dataclass(frozen=True) +class Binding: + """One criterion in the intent, in ontology terms: attribute `rel` value.""" + + attr: str + rel: str + value: Any + + +# op → the universal generic-CRUD verb every adapter supports (no adapter-specific verb map, no +# keywords): a change intent executes through resource.* — the deterministic write spine. +OP_TO_RESOURCE: dict[str, str] = { + "create": "resource.create", + "update": "resource.update", + "remove": "resource.delete", +} + + +@dataclass(frozen=True) +class Change: + """The write shape of an intent: what to make true. Resolved → propose→commit→tier (governed).""" + + op: str # create | update | remove + set: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Intent: + """The single payload the model emits — *what* it wants, never *how*.""" + + about: str # ontology type / entity (adapter-agnostic) + where: tuple[Binding, ...] = () + seek: str = "all" # the | all | count | summary (read shapes) + change: Change | None = None # present → a write intent (governed via propose→commit→tier) + limit: int = 50 + cursor: str | None = None + + +@dataclass(frozen=True) +class Outcome: + """The deterministic answer: a small result, a held proposal (writes), or a structured refusal + that carries the corrective parameter so recovery needs no reasoning.""" + + kind: str # "result" | "proposal" | "refusal" + value: Any = None + code: str | None = None + fix: str = "" + + @classmethod + def result(cls, value: Any) -> "Outcome": + return cls("result", value=value) + + @classmethod + def refusal(cls, code: str, fix: str = "") -> "Outcome": + return cls("refusal", code=code, fix=fix) + + +class BindingResolver(Protocol): + """Maps ontology terms to a concrete adapter target/field. The graph implements the real ontology + mapping; the identity resolver is the default when about IS the target and attr IS the field.""" + + def resolve_target(self, about: str) -> str: ... + + def resolve_attr(self, about: str, attr: str) -> str: ... + + +class IdentityResolver: + def resolve_target(self, about: str) -> str: + return about + + def resolve_attr(self, about: str, attr: str) -> str: + return attr + + +class IntentResolver: + """Resolve an Intent → Outcome over a ReadPlane (reads) deterministically.""" + + def __init__(self, read_plane: ReadPlane, binding_resolver: BindingResolver | None = None) -> None: + self._plane = read_plane + self._bind = binding_resolver or IdentityResolver() + + def resolve(self, intent: Intent, *, grant_fields: Any = None) -> Outcome: + if intent.seek not in SEEK_SHAPES: + return Outcome.refusal("INVALID_SEEK", f"seek must be one of {sorted(SEEK_SHAPES)}") + try: + target = self._bind.resolve_target(intent.about) + filt = [ + {"field": self._bind.resolve_attr(intent.about, b.attr), "op": REL_TO_OP[b.rel], "value": b.value} + for b in intent.where + ] + except KeyError as exc: + return Outcome.refusal("INVALID_REL", f"unknown relation {exc}; use one of {sorted(REL_TO_OP)}") + try: + if intent.seek == "count": + return Outcome.result(self._plane.count(target, filter=filt)) + if intent.seek == "the": + page = self._plane.search(target, filter=filt, fields=None, limit=1, grant_fields=grant_fields) + items = page.get("items", []) + return Outcome.result(items[0] if items else None) # not found = result None, never error + if intent.seek == "all": + page = self._plane.search( + target, filter=filt, fields=None, limit=intent.limit, cursor=intent.cursor, + grant_fields=grant_fields, + ) + return Outcome.result(page) + return Outcome.refusal("NOT_IMPLEMENTED", "summary (aggregate) lands in the next phase") + except (ResultTooLarge, CapabilityUnsupported, InvalidFilter) as exc: + return Outcome.refusal(getattr(exc, "code", "ERROR"), getattr(exc, "message", str(exc))) diff --git a/src/nilscript/mcp/server.py b/src/nilscript/mcp/server.py index 0e866b4..af9a7a3 100644 --- a/src/nilscript/mcp/server.py +++ b/src/nilscript/mcp/server.py @@ -316,6 +316,9 @@ async def nil_commit(proposal_id: str, ctx: Context = None) -> dict[str, Any]: async def nil_query(verb: str, args: dict[str, Any] | None = None, ctx: Context = None) -> dict[str, Any]: # type: ignore[assignment] return await provider.get(ctx).query(verb, args) + async def nil_intent(about: str, where: list[dict[str, Any]] | None = None, seek: str = "all", change: dict[str, Any] | None = None, limit: int = 50, cursor: str | None = None, ctx: Context = None) -> dict[str, Any]: # type: ignore[assignment] + return await provider.get(ctx).intent(about, where, seek, change, limit, cursor, session_id=session_key(ctx)) + async def nil_search(target: str, filter: list[dict[str, Any]] | None = None, fields: list[str] | None = None, limit: int = 50, cursor: str | None = None, ctx: Context = None) -> dict[str, Any]: # type: ignore[assignment] return await provider.get(ctx).search(target, filter, fields, limit, cursor) @@ -355,6 +358,16 @@ async def nil_rollback(compensation_token: str, reason: str, ctx: Context = None nil_query, name="nil_query", description="Read live business truth (verb + args). No side effect.", ) + server.add_tool( + nil_intent, name="nil_intent", + description="THE primary tool. Express WHAT you want as one payload — about (an entity, e.g. " + "res.partner), where ([{attr, rel, value}] with rel ∈ is|contains|gt|gte|lt|lte|between|in), and " + "either seek (the|all|count|summary, a read) OR change ({op:create|update|remove, set:{...}}, a " + "governed write). Reads return a lean result; a change returns a PREVIEW the gate/owner commits. " + "You NEVER pick a verb, build a filter, or list to scan. " + "Find دينا → about='res.partner', where=[{attr:'name',rel:'contains',value:'دينا'}], seek='the'. " + "Update her phone → about='res.partner', where=[{attr:'name',rel:'contains',value:'دينا'}], change={op:'update', set:{phone:'…'}}.", + ) server.add_tool( nil_search, name="nil_search", description="Lean, FILTERED, PAGINATED read of a target (filter=[{field,op,value}], small fields=, " diff --git a/src/nilscript/mcp/tools.py b/src/nilscript/mcp/tools.py index 02ea54c..b97e020 100644 --- a/src/nilscript/mcp/tools.py +++ b/src/nilscript/mcp/tools.py @@ -25,7 +25,7 @@ import httpx -from nilscript.dataplane import ResultTooLarge, enforce_byte_cap +from nilscript.dataplane import OP_TO_RESOURCE, ResultTooLarge, enforce_byte_cap from nilscript.sdk.client import NilClient from nilscript.sdk.connect import handshake from nilscript.sdk.idempotency import commit_idempotency_key @@ -153,6 +153,59 @@ async def search( {"target": target, "filter": filter or [], "fields": fields, "limit": limit, "cursor": cursor}, ) + async def intent( + self, + about: str, + where: list[dict[str, Any]] | None = None, + seek: str = "all", + change: dict[str, Any] | None = None, + limit: int = 50, + cursor: str | None = None, + *, + session_id: str | None = None, + ) -> dict[str, Any]: + """THE single payload — reads AND writes. Describe WHAT you want: an entity `about` + `where` + criteria + either a `seek` shape (the|all|count|summary, a read) or a `change` + {op: create|update|remove, set:{...}} (a governed write). The system resolves and executes it + deterministically — for a read it returns a lean result; for a change it returns a PREVIEW + (proposal) that the gate/owner commits. You never pick a verb or build a query.""" + if change: + return await self._intent_change(about, where or [], change, session_id=session_id) + return await self.query( + "nil.intent", + {"about": about, "where": where or [], "seek": seek, "limit": limit, "cursor": cursor}, + ) + + async def _intent_change( + self, about: str, where: list[dict[str, Any]], change: dict[str, Any], *, session_id: str | None + ) -> dict[str, Any]: + """A change intent → a governed proposal via the universal generic-CRUD spine (resource.*). + create writes `set`; update/remove first resolve the target record(s) by `where` (a read), then + propose per record. No adapter-specific verb map, no keyword matching, governance unchanged.""" + op = change.get("op") + verb = OP_TO_RESOURCE.get(op) + if verb is None: + return {"outcome": "refused", "code": "INVALID_OP", + "message": f"change.op must be one of {sorted(OP_TO_RESOURCE)}"} + fields = change.get("set") or {} + if op == "create": + return await self.propose(verb, {"target": about, **fields}, session_id=session_id) + # update / remove → resolve the target record(s) first (deterministic read), then propose. + read = await self.query( + "nil.intent", {"about": about, "where": where, "seek": "all", "limit": 25, "cursor": None} + ) + items = (read.get("value") or {}).get("items", []) if read.get("outcome") == "result" else [] + if not items: + return {"outcome": "refused", "code": "NO_MATCH", + "message": "no record matches the criteria; nothing to change"} + proposals = [] + for item in items: + args = {"target": about, "id": item.get("id")} + if op == "update": + args.update(fields) + proposals.append(await self.propose(verb, args, session_id=session_id)) + return proposals[0] if len(proposals) == 1 else {"outcome": "proposals", "items": proposals} + async def count(self, target: str, filter: Any = None) -> dict[str, Any]: """Just {count} — the first call for any 'how many / does X exist'. Never list to count.""" return await self.query("nil.count", {"target": target, "filter": filter or []}) diff --git a/tests/test_intent_reads.py b/tests/test_intent_reads.py new file mode 100644 index 0000000..8f6a3ff --- /dev/null +++ b/tests/test_intent_reads.py @@ -0,0 +1,96 @@ +"""Intent-as-the-only-payload (reads): the model emits a semantic Intent; the system deterministically +resolves it to a lean read and returns an Outcome. No tool selection, no filter built by the model, no +keyword matching. This is what makes "find دينا" work without depending on model intelligence. +""" + +from __future__ import annotations + +from nilscript.dataplane import ( + Binding, + FieldSpec, + IdentityResolver, + Intent, + IntentResolver, + ReadPlane, + TargetSchema, +) + + +class _Fake: + def __init__(self, rows): + self.rows = rows + self._schema = TargetSchema( + target="res.partner", + fields=(FieldSpec("id", "int", is_key=True), FieldSpec("name", "str"), FieldSpec("phone", "str")), + cardinality="large", + default_projection=("id", "name", "phone"), + ) + + def describe_target(self, target): + return self._schema if target == "res.partner" else None + + def _match(self, r, preds): + for p in preds: + v = r.get(p.field) + if p.op == "eq" and v != p.value: + return False + if p.op == "ilike" and str(p.value).lower() not in str(v or "").lower(): + return False + return True + + def fetch(self, target, *, predicates, fields, sort, limit, after_id): + rows = [r for r in self.rows if self._match(r, predicates) and (after_id is None or r["id"] > after_id)] + return sorted(rows, key=lambda r: r["id"])[:limit] + + def count(self, target, *, predicates): + return sum(1 for r in self.rows if self._match(r, predicates)) + + def get_one(self, target, record_id, fields): + return next((r for r in self.rows if r["id"] == record_id), None) + + def aggregate(self, target, *, predicates, group_by, metrics): + return None + + +def _contacts(): + rows = [{"id": i, "name": f"Contact {i}", "phone": f"+9745{i:07d}"} for i in range(40)] + rows.append({"id": 18, "name": "دينا كمال النجار", "phone": "+97455123456"}) + return rows + + +def _resolver(): + return IntentResolver(ReadPlane(_Fake(_contacts())), IdentityResolver()) + + +def test_seek_the_resolves_intent_to_one_lean_record() -> None: + # the model expressed: "the contact whose name contains دينا" — nothing more. + intent = Intent(about="res.partner", where=(Binding("name", "contains", "دينا"),), seek="the") + out = _resolver().resolve(intent) + assert out.kind == "result" + assert out.value == {"id": 18, "name": "دينا كمال النجار", "phone": "+97455123456"} + + +def test_seek_count_resolves_to_a_count() -> None: + out = _resolver().resolve(Intent(about="res.partner", where=(), seek="count")) + assert out.kind == "result" + assert out.value == {"count": 41} + + +def test_seek_all_returns_a_lean_bounded_page() -> None: + intent = Intent(about="res.partner", where=(Binding("name", "contains", "دينا"),), seek="all") + out = _resolver().resolve(intent) + assert out.kind == "result" + assert [r["id"] for r in out.value["items"]] == [18] + + +def test_seek_the_with_no_match_is_a_result_not_an_error() -> None: + intent = Intent(about="res.partner", where=(Binding("name", "contains", "غير موجود"),), seek="the") + out = _resolver().resolve(intent) + assert out.kind == "result" + assert out.value is None # "not found" — never an error, never invented + + +def test_unknown_about_is_a_structured_refusal() -> None: + out = _resolver().resolve(Intent(about="hr.salary", where=(), seek="count")) + assert out.kind == "refusal" + assert out.code # carries a code the agent can act on diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index c390ed9..6185b63 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -260,3 +260,62 @@ async def test_export_backstop_still_guards_a_misbehaving_export() -> None: tools, _ = make_tools() out = await tools.export("res.partner") assert out["code"] == "RESULT_TOO_LARGE" # even export results pass the relay backstop + + +@respx.mock +async def test_intent_sends_one_payload_and_backstops() -> None: + captured = {} + def _cap(request): + captured.update(json.loads(request.content)) + return httpx.Response(200, json={"data": {"outcome": "result", "value": {"id": 18, "name": "دينا"}}}) + respx.post(f"{BASE}/nil/v0.1/query").mock(side_effect=_cap) + tools, _ = make_tools() + out = await tools.intent("res.partner", where=[{"attr": "name", "rel": "contains", "value": "دينا"}], seek="the") + assert captured["body"]["verb"] == "nil.intent" + assert captured["body"]["args"]["about"] == "res.partner" + assert out["value"]["name"] == "دينا" + + +@respx.mock +async def test_intent_change_create_proposes_resource_create() -> None: + captured = [] + def route(request): + captured.append(json.loads(request.content)["body"]) + return httpx.Response(200, json=server_envelope("PROPOSAL", {**PROPOSAL_OK, "verb": "resource.create", "tier": "MEDIUM"})) + respx.post(f"{BASE}/nil/v0.1/propose").mock(side_effect=route) + tools, _ = make_tools() + out = await tools.intent("res.partner", change={"op": "create", "set": {"name": "Sara", "phone": "+9745"}}) + assert captured[-1]["verb"] == "resource.create" + assert captured[-1]["args"]["target"] == "res.partner" + assert captured[-1]["args"]["name"] == "Sara" + assert out["verb"] == "resource.create" + + +@respx.mock +async def test_intent_change_update_resolves_id_then_proposes() -> None: + respx.post(f"{BASE}/nil/v0.1/query").mock( + return_value=httpx.Response(200, json={"data": {"outcome": "result", "value": {"items": [{"id": 18, "name": "دينا"}]}}}) + ) + captured = [] + def route(request): + captured.append(json.loads(request.content)["body"]) + return httpx.Response(200, json=server_envelope("PROPOSAL", {**PROPOSAL_OK, "verb": "resource.update", "tier": "MEDIUM"})) + respx.post(f"{BASE}/nil/v0.1/propose").mock(side_effect=route) + tools, _ = make_tools() + out = await tools.intent("res.partner", where=[{"attr": "name", "rel": "contains", "value": "دينا"}], + change={"op": "update", "set": {"phone": "+97400000000"}}) + assert captured[-1]["verb"] == "resource.update" + assert captured[-1]["args"]["id"] == 18 # id resolved from the read + assert captured[-1]["args"]["phone"] == "+97400000000" + assert out["verb"] == "resource.update" + + +@respx.mock +async def test_intent_change_update_no_match_is_refusal() -> None: + respx.post(f"{BASE}/nil/v0.1/query").mock( + return_value=httpx.Response(200, json={"data": {"outcome": "result", "value": {"items": []}}}) + ) + tools, _ = make_tools() + out = await tools.intent("res.partner", where=[{"attr": "name", "rel": "contains", "value": "زز"}], + change={"op": "update", "set": {"phone": "+9"}}) + assert out["outcome"] == "refused" and out["code"] == "NO_MATCH"