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
3 changes: 3 additions & 0 deletions src/nilscript/dataplane/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def enforce_byte_cap(payload: Any, cap: int = BYTE_CAP) -> Any:
NotAuthorizedHandle,
)
from .bulk import BulkResult, run_bulk # noqa: E402
from .router import IntentProvider, IntentRouter # noqa: E402
from .intent import ( # noqa: E402
OP_TO_RESOURCE,
REL_TO_OP,
Expand All @@ -165,7 +166,9 @@ def enforce_byte_cap(payload: Any, cap: int = BYTE_CAP) -> Any:
"OP_TO_RESOURCE",
"IdentityResolver",
"Intent",
"IntentProvider",
"IntentResolver",
"IntentRouter",
"Outcome",
"BulkResult",
"EDGE_FILTER_BOUND",
Expand Down
39 changes: 39 additions & 0 deletions src/nilscript/dataplane/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""The universal intent router. One `nil_intent` surface fronts many execution domains; each domain is
an `IntentProvider` that declares which `about` types it OWNS and how to resolve an intent over them.

The router picks the first owning provider — a structural ownership check on the entity type, never
keyword matching of the user's words. New domains (graph, automation, governance) are added by
registering a provider, not by branching: the single payload covers 100% of the system, extensibly.
"""

from __future__ import annotations

from typing import Protocol

from .intent import Intent, Outcome


class IntentProvider(Protocol):
"""One execution domain. `owns` is a structural check on the entity type; `resolve` runs the intent
against that domain's layer (adapter / graph store / automation registry / ledger)."""

def owns(self, about: str) -> bool: ...

async def resolve(self, intent: Intent) -> Outcome: ...


class IntentRouter:
"""Route an Intent to the provider that owns its `about`. The model emits ONE payload; the router
delegates deterministically. Order is the tie-break (first owning provider wins)."""

def __init__(self, providers: list[IntentProvider]) -> None:
self._providers = list(providers)

async def resolve(self, intent: Intent) -> Outcome:
for provider in self._providers:
if provider.owns(intent.about):
return await provider.resolve(intent)
return Outcome.refusal(
"UNKNOWN_ABOUT",
f"no execution domain serves '{intent.about}' — check the available entities",
)
20 changes: 14 additions & 6 deletions src/nilscript/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ def build_tools(
scopes: frozenset[str] | None = None,
session_id: str = "mcp-session",
gate: str = "two-step",
brain: Any = None,
) -> NilTools:
"""Wire the SDK client to the adapter and wrap it in the MCP tool surface.

Mirrors `cli._cmd_run` so local-run and MCP behave identically against the same shim.
Mirrors `cli._cmd_run` so local-run and MCP behave identically against the same shim. `brain` (a
BrainTools, optional) is the graph/meta execution domain behind nil_intent's router.
"""
grant = GrantRef.from_secret(
grant_id=grant_id,
Expand All @@ -65,7 +67,7 @@ def build_tools(
)
transport = NilTransport(base_url=adapter_url, bearer_secret=bearer)
client = NilClient(transport=transport, grant=grant)
return NilTools(client, transport, session_id=session_id, gate=gate)
return NilTools(client, transport, session_id=session_id, gate=gate, brain=brain)


class ToolsProvider:
Expand Down Expand Up @@ -364,8 +366,12 @@ async def nil_rollback(compensation_token: str, reason: str, ctx: Context = None
"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. "
"You NEVER pick a verb, build a filter, or list to scan. `about` spans BOTH business entities "
"(res.partner, crm.lead — routed to the backend) AND the Business Graph (policy, cycle, role, "
"instance, overview, activity — routed to the brain). This is the ONE tool for reading and "
"changing anything in the system. "
"Find دينا → about='res.partner', where=[{attr:'name',rel:'contains',value:'دينا'}], seek='the'. "
"Show policies → about='policy', seek='all'. Show business cycles → about='cycle', seek='all'. "
"Update her phone → about='res.partner', where=[{attr:'name',rel:'contains',value:'دينا'}], change={op:'update', set:{phone:'…'}}.",
)
server.add_tool(
Expand Down Expand Up @@ -586,9 +592,12 @@ def build_asgi_app(
verbs: list[str] = []
if dynamic_tools and not multi_tenant:
verbs = asyncio.run(_discover_verbs(adapter_url, bearer))
from nilscript.mcp.brain_tools import BrainTools

brain = BrainTools.from_env() # graph/meta domain behind nil_intent (None if NIL_BRAIN_URL unset)
tools = build_tools(
adapter_url=adapter_url, grant_id=grant_id, workspace=workspace,
bearer=bearer, scopes=scopes, gate=gate,
bearer=bearer, scopes=scopes, gate=gate, brain=brain,
)
provider: ToolsProvider | None = None
if multi_tenant:
Expand All @@ -603,12 +612,11 @@ def build_asgi_app(
registry=make_registry_lookup(),
)
from nilscript.mcp.automation_tools import AutomationTools
from nilscript.mcp.brain_tools import BrainTools

server = build_server(
tools, dynamic_verbs=verbs, tools_provider=provider,
automation_tools=AutomationTools.from_env(),
brain_tools=BrainTools.from_env(),
brain_tools=brain,
allowed_hosts=_allowed_hosts_from_env(),
)
app = server.streamable_http_app() # MCP mounted at /mcp
Expand Down
91 changes: 85 additions & 6 deletions src/nilscript/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,16 @@

import httpx

from nilscript.dataplane import OP_TO_RESOURCE, ResultTooLarge, enforce_byte_cap
from nilscript.dataplane import (
OP_TO_RESOURCE,
Binding,
Change,
Intent,
IntentRouter,
Outcome,
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 @@ -69,13 +78,15 @@ def __init__(
*,
session_id: str = "mcp-session",
gate: str = "two-step",
brain: Any = None,
) -> None:
if gate not in GATE_MODES:
raise ValueError(f"gate must be one of {sorted(GATE_MODES)}, got {gate!r}")
self._client = client
self._transport = transport
self._default_session = session_id
self._gate = gate
self._brain = brain # optional BrainTools — owns graph/meta entities in nil_intent routing
self._proposals: dict[str, dict[str, dict[str, Any]]] = {}

def _sid(self, session_id: str | None) -> str:
Expand Down Expand Up @@ -169,12 +180,36 @@ async def intent(
{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},
intent_obj = Intent(
about=about,
where=tuple(Binding(attr=b.get("attr"), rel=b.get("rel"), value=b.get("value")) for b in (where or [])),
seek=seek,
change=Change(op=change.get("op"), set=change.get("set") or {}) if change else None,
limit=limit,
cursor=cursor,
)
providers: list[Any] = []
if self._brain is not None:
providers.append(_GraphIntentProvider(self._brain))
providers.append(_AdapterIntentProvider(self, session_id)) # fallback: owns business entities
outcome = await IntentRouter(providers).resolve(intent_obj)
if outcome.kind == "refusal":
return {"outcome": "refused", "code": outcome.code, "message": outcome.fix}
return outcome.value # providers return the wire-shaped response dict as the Outcome value

async def _adapter_resolve(self, intent_obj: Any, session_id: str | None) -> dict[str, Any]:
"""The adapter execution domain: reads via the adapter's nil.intent verb, writes via the
universal generic-CRUD spine (resource.*) — the logic unchanged, now behind a provider."""
where = [{"attr": b.attr, "rel": b.rel, "value": b.value} for b in intent_obj.where]
if intent_obj.change is not None:
return await self._intent_change(
intent_obj.about, where, {"op": intent_obj.change.op, "set": intent_obj.change.set},
session_id=session_id,
)
return await self.query("nil.intent", {
"about": intent_obj.about, "where": where, "seek": intent_obj.seek,
"limit": intent_obj.limit, "cursor": intent_obj.cursor,
})

async def _intent_change(
self, about: str, where: list[dict[str, Any]], change: dict[str, Any], *, session_id: str | None
Expand Down Expand Up @@ -310,3 +345,47 @@ async def _gate_decision(self, sid: str, proposal_id: str) -> dict[str, Any] | N
except httpx.HTTPError:
pass
return self._approval_required(tier)


# ── intent execution domains (providers behind the single nil_intent surface) ────────────────────
class _GraphIntentProvider:
"""The graph/meta domain: ont ology entities (cycles, policies, roles, instances, overview,
activity) resolve against the Business Graph (brain). A structural `about` set — not keywords."""

_READS = {"cycle", "cycles", "overview", "instance", "instances", "activity",
"policy", "role", "entity", "node", "graph"}

def __init__(self, brain: Any) -> None:
self._brain = brain

def owns(self, about: str) -> bool:
return about in self._READS

async def resolve(self, intent: Any) -> Outcome:
a = intent.about
if a in ("cycle", "cycles"):
data = await self._brain.cycles()
elif a == "overview":
data = await self._brain.overview()
elif a in ("instance", "instances"):
data = await self._brain.instances()
elif a == "activity":
data = await self._brain.activity()
else: # policy / role / entity / node / graph → the graph nodes (kind-filtered where natural)
data = await self._brain.graph(kind=a if a in ("policy", "role") else None)
return Outcome.result(data)


class _AdapterIntentProvider:
"""The business domain: anything not claimed by a more specific provider resolves against the active
adapter (reads via nil.intent, writes via the governed resource.* spine). The catch-all fallback."""

def __init__(self, tools: "NilTools", session_id: str | None) -> None:
self._tools = tools
self._session_id = session_id

def owns(self, about: str) -> bool:
return True

async def resolve(self, intent: Any) -> Outcome:
return Outcome.result(await self._tools._adapter_resolve(intent, self._session_id))
60 changes: 60 additions & 0 deletions tests/test_intent_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""The universal intent router: one nil_intent surface, many execution domains. `about` is routed to
the provider that OWNS it (adapter / graph / automation) — a structural ownership check, never keyword
matching of the user's words. This is what collapses nil_graph/nil_automation/nil_propose/... into the
single Intent payload while keeping each domain's execution layer behind its own provider.
"""

from __future__ import annotations

import pytest

from nilscript.dataplane import Intent, IntentRouter, Outcome


class _FakeProvider:
def __init__(self, owns_types: set[str], value) -> None:
self._owns = owns_types
self._value = value
self.seen: Intent | None = None

def owns(self, about: str) -> bool:
return about in self._owns

async def resolve(self, intent: Intent) -> Outcome:
self.seen = intent
return Outcome.result(self._value)


async def test_router_delegates_to_the_owning_provider() -> None:
adapter = _FakeProvider({"res.partner"}, {"items": []})
graph = _FakeProvider({"policy", "cycle"}, {"policies": ["payment-approval"]})
router = IntentRouter([graph, adapter])

out = await router.resolve(Intent(about="policy", seek="all"))

assert out.kind == "result" and out.value == {"policies": ["payment-approval"]}
assert graph.seen is not None and adapter.seen is None # routed by ownership, not order


async def test_router_routes_business_entity_to_the_adapter_provider() -> None:
adapter = _FakeProvider({"res.partner"}, {"items": [{"id": 18}]})
graph = _FakeProvider({"policy"}, {})
router = IntentRouter([graph, adapter])

out = await router.resolve(Intent(about="res.partner", seek="the"))

assert out.value == {"items": [{"id": 18}]}
assert adapter.seen is not None and graph.seen is None


async def test_router_refuses_an_about_no_provider_owns() -> None:
router = IntentRouter([_FakeProvider({"res.partner"}, {})])
out = await router.resolve(Intent(about="nonsense.thing", seek="all"))
assert out.kind == "refusal" and out.code == "UNKNOWN_ABOUT"


async def test_first_owning_provider_wins() -> None:
a = _FakeProvider({"shared"}, {"from": "a"})
b = _FakeProvider({"shared"}, {"from": "b"})
out = await IntentRouter([a, b]).resolve(Intent(about="shared", seek="all"))
assert out.value == {"from": "a"}
30 changes: 30 additions & 0 deletions tests/test_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,33 @@ async def test_intent_change_update_no_match_is_refusal() -> None:
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"


class _FakeBrain:
async def cycles(self): return {"cycles": ["sales", "finance"]}
async def overview(self): return {"overview": True}
async def instances(self): return {"instances": []}
async def activity(self): return {"activity": []}
async def graph(self, kind=None): return {"nodes": [], "kind": kind}


async def test_intent_routes_graph_entity_to_the_brain() -> None:
transport = NilTransport(base_url=BASE, bearer_secret=GRANT.bearer_secret())
client = NilClient(transport=transport, grant=GRANT)
tools = NilTools(client, transport, session_id=SESSION, brain=_FakeBrain())
out = await tools.intent("cycle", seek="all") # graph entity → brain, no adapter call
assert out == {"cycles": ["sales", "finance"]}
out2 = await tools.intent("policy", seek="all") # policy → graph nodes, kind-filtered
assert out2["kind"] == "policy"


@respx.mock
async def test_intent_routes_business_entity_to_the_adapter_even_with_brain() -> None:
respx.post(f"{BASE}/nil/v0.1/query").mock(
return_value=httpx.Response(200, json={"data": {"outcome": "result", "value": {"items": [{"id": 18}]}}})
)
transport = NilTransport(base_url=BASE, bearer_secret=GRANT.bearer_secret())
client = NilClient(transport=transport, grant=GRANT)
tools = NilTools(client, transport, session_id=SESSION, brain=_FakeBrain())
out = await tools.intent("res.partner", where=[{"attr": "name", "rel": "contains", "value": "دينا"}], seek="the")
assert out["value"]["items"] == [{"id": 18}] # routed to the adapter, not the brain
Loading