From 695eb78098c04db42b1046be5fcbd5f1890ee52b Mon Sep 17 00:00:00 2001 From: AI Bot Date: Fri, 26 Jun 2026 18:08:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(intent):=20universal=20IntentRouter=20?= =?UTF-8?q?=E2=80=94=20one=20nil=5Fintent=20surface=20across=20all=20domai?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model emits ONE Intent for EVERYTHING; an IntentRouter delegates `about` to the provider that OWNS it (a structural ownership check on the entity type, never keyword matching). New domains are added by registering a provider, not by branching. - dataplane/router.py: IntentProvider protocol + IntentRouter (first owning provider wins). - relay providers: _GraphIntentProvider (cycle/policy/role/instance/overview/activity → Business Graph/brain) + _AdapterIntentProvider (business entities → adapter, fallback). - NilTools gains an optional brain; build_tools/build_server wire BrainTools once, shared by nil_intent's router and the legacy graph tools. So nil_intent now reads business data AND the graph through one payload. Collapses the agent surface toward a single tool. 441 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/nilscript/dataplane/__init__.py | 3 + src/nilscript/dataplane/router.py | 39 +++++++++++++ src/nilscript/mcp/server.py | 20 +++++-- src/nilscript/mcp/tools.py | 91 +++++++++++++++++++++++++++-- tests/test_intent_router.py | 60 +++++++++++++++++++ tests/test_mcp_tools.py | 30 ++++++++++ 6 files changed, 231 insertions(+), 12 deletions(-) create mode 100644 src/nilscript/dataplane/router.py create mode 100644 tests/test_intent_router.py diff --git a/src/nilscript/dataplane/__init__.py b/src/nilscript/dataplane/__init__.py index 7f64507..a8e3d4f 100644 --- a/src/nilscript/dataplane/__init__.py +++ b/src/nilscript/dataplane/__init__.py @@ -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, @@ -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", diff --git a/src/nilscript/dataplane/router.py b/src/nilscript/dataplane/router.py new file mode 100644 index 0000000..ac3e6f8 --- /dev/null +++ b/src/nilscript/dataplane/router.py @@ -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", + ) diff --git a/src/nilscript/mcp/server.py b/src/nilscript/mcp/server.py index af9a7a3..93ea272 100644 --- a/src/nilscript/mcp/server.py +++ b/src/nilscript/mcp/server.py @@ -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, @@ -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: @@ -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( @@ -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: @@ -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 diff --git a/src/nilscript/mcp/tools.py b/src/nilscript/mcp/tools.py index b97e020..2ce0f6f 100644 --- a/src/nilscript/mcp/tools.py +++ b/src/nilscript/mcp/tools.py @@ -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 @@ -69,6 +78,7 @@ 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}") @@ -76,6 +86,7 @@ def __init__( 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: @@ -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 @@ -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)) diff --git a/tests/test_intent_router.py b/tests/test_intent_router.py new file mode 100644 index 0000000..98aaa58 --- /dev/null +++ b/tests/test_intent_router.py @@ -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"} diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index 6185b63..64b1ee4 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -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