Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/PLAN-nil-intent.md
Original file line number Diff line number Diff line change
@@ -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: <ontology type> // 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).
20 changes: 20 additions & 0 deletions src/nilscript/dataplane/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
137 changes: 137 additions & 0 deletions src/nilscript/dataplane/intent.py
Original file line number Diff line number Diff line change
@@ -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)))
13 changes: 13 additions & 0 deletions src/nilscript/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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=, "
Expand Down
55 changes: 54 additions & 1 deletion src/nilscript/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 []})
Expand Down
Loading
Loading