simon824 commented on code in PR #380: URL: https://github.com/apache/hugegraph-ai/pull/380#discussion_r4336347809
########## ontogeny/server/ontogeny/demo/bootstrap.py: ########## @@ -0,0 +1,245 @@ +# Copyright 2026 Apache HugeGraph Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Demo bootstrap: a complete, runnable installation from one command. + +Creates a self-contained data directory containing + + <root>/ontogeny.db metadata + object tables (SQLite) + <root>/erp.db a seeded *source* database (stands in for ERP/MES) + <root>/pkg/ a copy of the example ontology package (sqlite-ized) + +The package is COPIED, never mutated in place: the example tree stays the +golden reference for tests, and demo-mode promotions write into the copy +(revert = delete the directory). +""" +from __future__ import annotations + +import shutil +import sqlite3 +from dataclasses import dataclass +from pathlib import Path + +import yaml + +DEFAULT_DEMO_DIR = ".ontogeny-demo" +DEFAULT_PACKAGE = "domains/product-manufacturing" + +# Source rows for the demo: 2 pieces of equipment, 3 work orders, 2 spare parts. +EQUIPMENT = [ + ("E-001", "CNC-Alpha", "north", "POINT(120.1 30.2)", "DOWN", "2020-01-01"), + ("E-002", "Press-Beta", "south", "POINT(121.5 31.1)", "RUNNING", "2019-06-15"), +] +WORK_ORDERS = [ + ("WO-1001", "E-001", "主轴异响", "BREAKDOWN", "HIGH", "north", "OPEN", None, + "2026-09-17T08:00:00+00:00", None, "2026-09-17T08:00:00+00:00"), + ("WO-1002", "E-002", "液压油渗漏", "BREAKDOWN", "LOW", "south", "OPEN", None, + "2026-09-17T09:30:00+00:00", None, "2026-09-17T09:30:00+00:00"), + ("WO-1003", "E-001", "季度点检", "INSPECTION", "LOW", "north", "CLOSED", "已完成点检", + "2026-09-10T08:00:00+00:00", "2026-09-10T10:30:00+00:00", "2026-09-10T10:30:00+00:00"), +] +PARTS = [("P-001", "主轴轴承", "1280.00", "6"), ("P-002", "液压密封圈", "45.50", "40")] +WORK_ORDER_PARTS = [("WO-1003", "P-001")] + + +@dataclass(frozen=True) +class DemoPaths: + root: Path + db_dsn: str + package_root: Path + pristine_pkg_root: Path + erp_dsn: str + # True when the metadata/object store was created (or reset) by this call -- + # a fresh demo is worth acting out a story for, a reused one is not. + fresh: bool = False + + def as_env(self) -> dict[str, str]: + env = { + "ONTOGENY_DB_DSN": self.db_dsn, + "ONTOGENY_PACKAGE_ROOT": str(self.package_root), + "ONTOGENY_PRISTINE_PKG_ROOT": str(self.pristine_pkg_root), + "ERP_DSN": self.erp_dsn, + "WMS_WEBHOOK": "https://wms.example.test/hook", + } + # A demo is an explicitly local, throwaway context: keep the identity + # simulation available so the console works out of the box, unless the + # operator explicitly decided ONTOGENY_DEV_AUTH for this process. + import os + + if "ONTOGENY_DEV_AUTH" not in os.environ: + env["ONTOGENY_DEV_AUTH"] = "1" Review Comment: This is deliberate for the demo profile: `--demo` is a self-contained local try-out, and `as_env()` only sets `ONTOGENY_DEV_AUTH=1` if the operator did not set it (via `setdefault`, commit 4e1e130). A production deployment does not pass `--demo`, and the platform default is dev-auth **off**. The Docker CMD does use `--demo`, but the operator's env always wins. ########## ontogeny/server/ontogeny/policy/engine.py: ########## @@ -0,0 +1,167 @@ +# Copyright 2026 Apache HugeGraph Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Cedar-subset policy engine (default driver). + +Parses the ``permit(...) when { ... }`` statements used by PolicySet files: + + permit(principal, action == Action::"close-work-order", resource); + permit(principal in Role::"maintenance_supervisor", + action == Action::"close-work-order", resource) + when { resource.status != "CLOSED" && principal has site && resource.site == principal.site }; + +Evaluation is deterministic and default-deny. Conditions are translated to the +platform mini-expr (``&&``->``and``) so there is exactly ONE expression +semantics in the system. If the ``cedarpy`` binding is installed it is +preferred; this pure-Python driver is the portable fallback and CI reference. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +from ..errors import ExpressionError, PolicyDeniedError +from ..core import expr as _expr + +_PERMIT_RE = re.compile( + r"permit\s*\(\s*(?P<principal>[^,]+?)\s*,\s*action\s*==\s*Action::\"(?P<action>[^\"]+)\"\s*,\s*(?P<resource>[^)]*?)s*\)\s*(?:when\s*\{(?P<when>.*?)\}\s*)?;", + re.DOTALL, +) + + +@dataclass(frozen=True) +class PermitRule: + role: str | None # None = any authenticated principal + action: str + when: str | None # mini-expr after translation + + +@dataclass(frozen=True) +class PolicyDecision: + allow: bool + rule: PermitRule | None = None + reason: str = "" + + +def translate_when(cond: str) -> str: + """Cedar condition -> mini-expr: ``&&``/``||`` to and/or, ``resource`` to + ``target`` (the platform's single expression semantics).""" + out = re.sub(r"\bresource\b", "target", cond) + return out.replace("&&", " and ").replace("||", " or ") + + +def parse_policy_set(text: str) -> list[PermitRule]: + rules: list[PermitRule] = [] + for m in _PERMIT_RE.finditer(text): + principal = m.group("principal").strip() + role: str | None = None + rm = re.fullmatch(r'principal in (?:Role|Group)::"([^"]+)"', principal) + if rm: + role = rm.group(1) + elif principal != "principal": + continue # unsupported principal clause -> rule skipped (fail closed) + when = m.group("when") + rules.append(PermitRule(role=role, action=m.group("action"), when=translate_when(when.strip()) if when else None)) + return rules + + +class PolicyEngine: + """Decides principal x action x resource against the package's policy sets.""" + + def __init__(self, compiled) -> None: + self.compiled = compiled + self._sets: dict[str, list[PermitRule]] = {} + for name, text in compiled.cedar_texts.items(): + self._sets[name] = parse_policy_set(text) + if "default" not in self._sets: + self._sets["default"] = [] # absent default = default-deny + + # ------------------------------------------------------------------ core + + def rules_for(self, action_name: str) -> list[tuple[str, PermitRule]]: + """Every permit that names this action, across the whole policy store. + + A Cedar policy store is a *set* of policies and any permit grants. This + engine used to consult only the single set an action names in + ``spec.policy`` (falling back to ``default``) — a per-action shortcut + that silently ignores a permit written in any other set. That is exactly + the case the role manager needs: it generates one policy set per role + (``role-<name>``), which no action declares, so under the old rule those + grants were written to disk, compiled, published… and never consulted. + + Unioning is a no-op for packages whose sets do not share an action (the + example package: no action appears in two sets), and it makes the store + behave the way Cedar is documented to behave. The action's own set is + still listed first so a decision's "permit by <set>" reason keeps naming + the policy its author wrote. + """ + preferred = "default" + act = self.compiled.actions.get(action_name) + if act is not None and act.spec.policy: + preferred = act.spec.policy + ordered = [preferred, *(n for n in self._sets if n != preferred)] + return [ + (name, rule) + for name in ordered + for rule in self._sets.get(name, []) + if rule.action == action_name + ] + + def decide(self, principal: dict, action_name: str, resource: dict | None) -> PolicyDecision: + roles = set(principal.get("Role") or principal.get("roles") or []) + for policy_name, rule in self.rules_for(action_name): + if rule.role is not None and rule.role not in roles: Review Comment: This is correct Cedar semantics: `permit(principal, ...)` grants to every principal including the anonymous visitor. The golden package uses role-scoped permits (`principal in Role::"..."`) for its writes; the unscoped form appears in the docs as a syntax example. Every write additionally passes rules and the action's policy set, and deny-by-default applies to the anonymous visitor for all role-scoped grants. ########## ontogeny/server/ontogeny/policy/engine.py: ########## @@ -0,0 +1,167 @@ +# Copyright 2026 Apache HugeGraph Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Cedar-subset policy engine (default driver). + +Parses the ``permit(...) when { ... }`` statements used by PolicySet files: + + permit(principal, action == Action::"close-work-order", resource); + permit(principal in Role::"maintenance_supervisor", + action == Action::"close-work-order", resource) + when { resource.status != "CLOSED" && principal has site && resource.site == principal.site }; + +Evaluation is deterministic and default-deny. Conditions are translated to the +platform mini-expr (``&&``->``and``) so there is exactly ONE expression +semantics in the system. If the ``cedarpy`` binding is installed it is +preferred; this pure-Python driver is the portable fallback and CI reference. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +from ..errors import ExpressionError, PolicyDeniedError +from ..core import expr as _expr + +_PERMIT_RE = re.compile( + r"permit\s*\(\s*(?P<principal>[^,]+?)\s*,\s*action\s*==\s*Action::\"(?P<action>[^\"]+)\"\s*,\s*(?P<resource>[^)]*?)s*\)\s*(?:when\s*\{(?P<when>.*?)\}\s*)?;", + re.DOTALL, +) + + +@dataclass(frozen=True) +class PermitRule: + role: str | None # None = any authenticated principal + action: str + when: str | None # mini-expr after translation + + +@dataclass(frozen=True) +class PolicyDecision: + allow: bool + rule: PermitRule | None = None + reason: str = "" + + +def translate_when(cond: str) -> str: + """Cedar condition -> mini-expr: ``&&``/``||`` to and/or, ``resource`` to + ``target`` (the platform's single expression semantics).""" + out = re.sub(r"\bresource\b", "target", cond) + return out.replace("&&", " and ").replace("||", " or ") + + +def parse_policy_set(text: str) -> list[PermitRule]: + rules: list[PermitRule] = [] + for m in _PERMIT_RE.finditer(text): + principal = m.group("principal").strip() + role: str | None = None + rm = re.fullmatch(r'principal in (?:Role|Group)::"([^"]+)"', principal) + if rm: + role = rm.group(1) + elif principal != "principal": + continue # unsupported principal clause -> rule skipped (fail closed) Review Comment: You're right that the resource clause is captured but not evaluated — this is a v0 scope decision. The engine's contract (documented in the module docstring) is a Cedar subset: principal scoping, action matching, and when-clause conditions. Full resource constraints (`resource in ...`, `resource.attr == ...`) are on the roadmap; for now, marking-based masking covers the resource attribute surface. ########## ontogeny/server/ontogeny/stores/repo.py: ########## @@ -0,0 +1,432 @@ +# Copyright 2026 Apache HugeGraph Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Versioned object repository: the ONLY component that reads/writes object +tables. Sync path respects property ownership; action path is used exclusively +by ActionRuntime (ownership already validated at publish time). +""" +from __future__ import annotations + +import datetime as _dt +from typing import Any, Iterable, Sequence + +from sqlalchemy import and_, func, select, update +from sqlalchemy.sql.elements import ColumnElement +from sqlalchemy.engine import RowMapping + +from ..errors import ConflictError, NotFoundError, StoreError +from .ddl import ensure_tables, object_table + + +def utcnow() -> _dt.datetime: + return _dt.datetime.now(_dt.timezone.utc) + + +def _and(*conds): + """and_() that drops None conditions (build_filter returns None for no filter).""" + real = [c for c in conds if c is not None] + return and_(*real) if real else None + + +class ObjectRepository: + def __init__(self, compiled) -> None: + self.compiled = compiled + + # ------------------------------------------------------------------ util + + def _table(self, object_type: str): + if object_type not in self.compiled.objects: + raise NotFoundError(f"unknown object type {object_type!r}") + return object_table(self.compiled, object_type) + + def _prop_cols(self, object_type: str) -> list[str]: + """Columns written by sync/actions: everything except derived properties + (expression deriveds are computed at read; function deriveds are + materialized by the derivation worker).""" + obj = self.compiled.objects[object_type] + return [ + self.compiled.col(p) for p, d in obj.spec.properties.items() if d.derived is None + ] + + def _pk_cols(self, object_type: str) -> list[str]: + return [self.compiled.col(p) for p in self.compiled.objects[object_type].spec.primaryKey] + + def _pk(self, object_type: str, t) -> list: + """Primary-key Column objects for the object's table.""" + return [getattr(t.c, c) for c in self._pk_cols(object_type)] + + def row_to_props(self, object_type: str, row: RowMapping) -> dict[str, Any]: + """Column dict -> property dict (adds _rev/_valid_from/_valid_to/_synced_at).""" + out: dict[str, Any] = {k: v for k, v in row.items() if not k.startswith("_")} + out.update({k: row.get(k) for k in ("_rev", "_valid_from", "_valid_to", "_synced_at")}) + return out + + async def ensure(self, session) -> None: + await session.connection() + conn = await session.connection() + await conn.run_sync(lambda c: ensure_tables(c, self.compiled)) + + # ------------------------------------------------------------------ reads + + def _current_where(self, object_type: str, at: _dt.datetime | None): + t = self._table(object_type) + if at is None: + return t.c._valid_to.is_(None) + return and_(t.c._valid_from <= at, (t.c._valid_to.is_(None)) | (t.c._valid_to > at)) + + async def get( + self, session, object_type: str, obj_id: str | Sequence[str], + *, at: _dt.datetime | None = None, for_update: bool = False, + ) -> dict[str, Any] | None: + t = self._table(object_type) + pk_cols = self._pk_cols(object_type) + ids = [obj_id] if isinstance(obj_id, str) else list(obj_id) + if len(ids) != len(pk_cols): + raise StoreError(f"expected {len(pk_cols)} key value(s), got {len(ids)}") + pk_columns = self._pk(object_type, t) + where = _and(self._current_where(object_type, at), *[c == v for c, v in zip(pk_columns, ids)]) + stmt = select(t).where(where).limit(1) + if for_update: + stmt = stmt.with_for_update() + row = (await session.execute(stmt)).mappings().first() + return self.row_to_props(object_type, row) if row else None + + async def get_many( + self, session, object_type: str, ids: Iterable[str], *, at: _dt.datetime | None = None, + ) -> list[dict[str, Any]]: + t = self._table(object_type) + pk = self._pk(object_type, t)[0] + stmt = select(t).where(_and(self._current_where(object_type, at), pk.in_(list(ids)))) + rows = (await session.execute(stmt)).mappings().all() + return [self.row_to_props(object_type, r) for r in rows] + + async def history(self, session, object_type: str, obj_id: str) -> list[dict[str, Any]]: + t = self._table(object_type) + pk = self._pk(object_type, t)[0] + stmt = select(t).where(pk == obj_id).order_by(t.c._valid_from) + return [self.row_to_props(object_type, r) for r in (await session.execute(stmt)).mappings().all()] + + def build_filter(self, object_type: str, filt: Any) -> ColumnElement | None: + """Compile the JSON filter AST to a SQLAlchemy expression. + + Forms: {"field","op","value"} | {"and":[...]} | {"or":[...]} | {"not":F} + Shorthand {"prop": value} -> eq. Ops: eq ne lt le gt ge in contains isnull. + """ + t = self._table(object_type) + if filt is None: + return None + + def one(f: dict) -> ColumnElement: + if "and" in f: + return and_(*[one(x) for x in f["and"]]) + if "or" in f: + from sqlalchemy import or_ + + return or_(*[one(x) for x in f["or"]]) + if "not" in f: + return ~one(f["not"]) + if "field" in f: + name, op, value = f["field"], f.get("op", "eq"), f.get("value") + else: + # shorthand: {"a": 1, "b": 2} == a==1 AND b==2 + return _and(*[one({"field": k, "op": "eq", "value": v}) for k, v in f.items()]) + if name.startswith("_"): + raise StoreError(f"filter on system column {name!r} is not allowed") + col = getattr(t.c, self.compiled.col(name), None) Review Comment: Known limitation, acknowledged in the docs. Filter/sort on a marked property is an inference channel — the DB layer filters, masking happens after. The aggregate endpoint is already closed (marked fields are refused for principals without the claim). A full fix requires pushing the marking check into the query path, which is on the roadmap. ########## ontogeny/server/ontogeny/stores/repo.py: ########## @@ -0,0 +1,432 @@ +# Copyright 2026 Apache HugeGraph Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Versioned object repository: the ONLY component that reads/writes object +tables. Sync path respects property ownership; action path is used exclusively +by ActionRuntime (ownership already validated at publish time). +""" +from __future__ import annotations + +import datetime as _dt +from typing import Any, Iterable, Sequence + +from sqlalchemy import and_, func, select, update +from sqlalchemy.sql.elements import ColumnElement +from sqlalchemy.engine import RowMapping + +from ..errors import ConflictError, NotFoundError, StoreError +from .ddl import ensure_tables, object_table + + +def utcnow() -> _dt.datetime: + return _dt.datetime.now(_dt.timezone.utc) + + +def _and(*conds): + """and_() that drops None conditions (build_filter returns None for no filter).""" + real = [c for c in conds if c is not None] + return and_(*real) if real else None + + +class ObjectRepository: + def __init__(self, compiled) -> None: + self.compiled = compiled + + # ------------------------------------------------------------------ util + + def _table(self, object_type: str): + if object_type not in self.compiled.objects: + raise NotFoundError(f"unknown object type {object_type!r}") + return object_table(self.compiled, object_type) + + def _prop_cols(self, object_type: str) -> list[str]: + """Columns written by sync/actions: everything except derived properties + (expression deriveds are computed at read; function deriveds are + materialized by the derivation worker).""" + obj = self.compiled.objects[object_type] + return [ + self.compiled.col(p) for p, d in obj.spec.properties.items() if d.derived is None + ] + + def _pk_cols(self, object_type: str) -> list[str]: + return [self.compiled.col(p) for p in self.compiled.objects[object_type].spec.primaryKey] + + def _pk(self, object_type: str, t) -> list: + """Primary-key Column objects for the object's table.""" + return [getattr(t.c, c) for c in self._pk_cols(object_type)] + + def row_to_props(self, object_type: str, row: RowMapping) -> dict[str, Any]: + """Column dict -> property dict (adds _rev/_valid_from/_valid_to/_synced_at).""" + out: dict[str, Any] = {k: v for k, v in row.items() if not k.startswith("_")} Review Comment: This is correct: `row_to_props` returns column keys, and `mask` is keyed by property name. The `compiled.col()` function ensures property → column is deterministic (snake_case), but two properties can collide to the same column. The validator should catch this; I'll add a `PROP-COLLISION` check in a follow-up. ########## ontogeny/server/ontogeny/functions/declarative.py: ########## @@ -0,0 +1,345 @@ +# Copyright 2026 Apache HugeGraph Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Declarative functions: a prompt-shaped body the platform executes itself. + +A code function is a file in the package; a declarative one has no file at all. +Its body is a short pipeline of the three things a "smart little function" +actually does — read objects, ask a model, call an endpoint — written in the DSL +where it can be edited from the console, diffed, and pinned by content version. + +**The whole point is that it borrows the sandbox's guarantees.** Every step runs +in-process, through exactly the paths a code function reaches over RPC: + +* ``read`` goes through the query engine and returns the same assembled rows a + code function's ``ontogeny.query`` gets, including properties that carry a ``marking``. + + That last part is deliberate, and it is the platform's rule for BOTH runtimes: + a function body is *trusted, reviewed, capability-scoped server-side logic*, + not a reader. The system example depends on it — ``maintenance-planning`` + reads ``equipment.site`` (marked ``internal``) so that the row-level + authorization attribute is taken from the authoritative record instead of from + the caller ("绝不能由调用方传入"). Masking is a READER-surface control (REST, + MCP, graph preview); ``read-objects`` is the function-surface control. Applying + masks here would not harden the platform, it would break that pattern — and + silently, as a policy denial three steps later. +* ``llm`` goes through the platform gateway with the capability's ``max-calls`` + budget, exactly like ``ontogeny.llm``; +* ``http`` is the platform making the request, not the function: an allowlist is + required, secrets stay in ``${VAR}`` references resolved at call time, and the + response is capped. + +What declarative functions intentionally do NOT have: loops, branches, or the +ability to write. Writes belong to Actions, where rules and policy already +govern them. +""" +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +import httpx + +from ..errors import SandboxError + +log = logging.getLogger("ontogeny.functions.declarative") + +#: ``${name}`` / ``${name.field}`` / ``${name[*].field}``. Deliberately not the +#: property-expression language: this is string interpolation over step results, +#: and pretending otherwise invites writing logic in a template. +_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)((?:\.\w+|\[\*\])*)\}") + +#: Response bodies from an http step are capped: a function must not be able to +#: pull an arbitrary amount of the internet into a result payload. +_MAX_RESPONSE_BYTES = 256 * 1024 + + +def _walk(value: Any, path: str) -> Any: + """Resolve ``.field`` / ``[*]`` segments against a step result or parameter.""" + cur = value + for seg in [s for s in path.split(".") if s]: + if seg == "[*]": + if not isinstance(cur, list): + raise SandboxError(f"cannot expand ${{...[*]}} on {type(cur).__name__}") + continue + if isinstance(cur, list): + # a list of rows: project the field from every row + cur = [row.get(seg) if isinstance(row, dict) else None for row in cur] + elif isinstance(cur, dict): + cur = cur.get(seg) + else: + raise SandboxError(f"cannot look up {seg!r} on {type(cur).__name__}") + return cur + + +def _render_text(value: Any) -> str: + """How a value reads inside a prompt. + + A list of rows becomes one line per row — the shape a model needs to reason + about records — rather than a JSON dump it has to parse first. + """ + if value is None: + return "" + if isinstance(value, list): + if not value: + return "(none)" + if all(isinstance(r, dict) for r in value): + return "\n".join( + "- " + ", ".join(f"{k}={v}" for k, v in r.items() if v is not None) + for r in value + ) + return "\n".join(f"- {_render_text(v)}" for v in value) + if isinstance(value, dict): + return ", ".join(f"{k}={v}" for k, v in value.items() if v is not None) + return str(value) + + +def _llm_budget(cfg: dict | None) -> int: + """How many model calls a declaration permits. + + ``max-calls: 0`` means ZERO, not "unset": the declaration is authoritative, + and a falsy-zero bug here would grant MORE calls than declared (10), which is + the one direction a budget must never fail in. + """ + if not cfg or cfg.get("max-calls") is None: + return 10 + return max(0, int(cfg["max-calls"])) + + +class _Template: + """Interpolation over a call's parameters and already-computed steps.""" + + def __init__(self, params: dict[str, Any], steps: dict[str, Any]) -> None: + self.params = params + self.steps = steps + + def refs(self, text: str) -> list[str]: + return [m.group(1) for m in _REF.finditer(text)] + + def resolve(self, ref: str, path: str) -> Any: + if ref in self.steps: + root = self.steps[ref] + elif ref in self.params: + root = self.params[ref] + else: + raise SandboxError(f"unknown reference ${{{ref}}}: not a parameter or an earlier step") + return _walk(root, path) + + def render_text(self, text: str) -> str: + def sub(m: "re.Match[str]") -> str: + return _render_text(self.resolve(m.group(1), m.group(2))) + + return _REF.sub(sub, text) + + def render(self, value: Any) -> Any: + """Recursively resolve a value. A string that is ONE bare reference keeps + the referenced value's own type (so ``returns: ${rows}`` returns a list); + a string with surrounding text is interpolated into a string.""" + if isinstance(value, str): + bare = _REF.fullmatch(value) + if bare: + return self.resolve(bare.group(1), bare.group(2)) + return self.render_text(value) + if isinstance(value, dict): + return {k: self.render(v) for k, v in value.items()} + if isinstance(value, list): + return [self.render(v) for v in value] + return value + + +class DeclarativeRuntime: + """Executes ``runtime: declarative`` functions against the platform itself. + + Collaborators are the same objects the sandbox talks to over RPC, so the two + runtimes cannot drift: `query` is the engine call the sandbox's ``query`` RPC + lands on, `llm` is the gateway adapter ``ontogeny.llm`` is routed to. + """ + + def __init__(self, *, llm=None, repo=None, compiled=None, + http_allowlist=None, resolve_ref=None, + llm_call_timeout_s: float = 60.0, + timeout_s: float = 30.0, http: httpx.AsyncClient | None = None) -> None: + # resolves ${VAR} in a capability's allow list / step urls (the same + # scope DSL endpoint references use) + self.resolve_ref = resolve_ref + self.llm = llm + self.repo = repo + self.compiled = compiled + # deployment-level allowlist: when set, an http step must satisfy BOTH it + # and the capability's own list + self.http_allowlist = tuple(http_allowlist or ()) + self.llm_call_timeout_s = llm_call_timeout_s + self.timeout_s = timeout_s + self._http = http + + async def client(self) -> httpx.AsyncClient: + if self._http is None: + self._http = httpx.AsyncClient(timeout=self.timeout_s, follow_redirects=False) + return self._http + + async def aclose(self) -> None: + if self._http is not None: + await self._http.aclose() + self._http = None + + # ---------------------------------------------------------------- guards + + @staticmethod + def _read_capability(fn) -> set[str]: + allowed: set[str] = set() + for cap in fn.spec.capabilities: + data = cap.model_dump(exclude_none=True) + if "read-objects" in data: + allowed.update(data["read-objects"]) + return allowed + + @staticmethod + def _llm_capability(fn) -> dict | None: + for cap in fn.spec.capabilities: + data = cap.model_dump(exclude_none=True) + if "llm" in data: + cfg = data["llm"] + return cfg if isinstance(cfg, dict) else {} + return None + + @staticmethod + def _http_capability(fn) -> dict | None: + for cap in fn.spec.capabilities: + data = cap.model_dump(exclude_none=True) + if "http" in data: + cfg = data["http"] + return cfg if isinstance(cfg, dict) else {} + return None + + def _allowed_hosts(self, fn) -> list[str]: + """Allowed hosts, with ``${VAR}`` entries resolved against the deployment. + + A host is environment-specific (staging and production never match), so a + literal list would force a DSL edit per environment — exactly what the + ``${VAR}`` convention exists to avoid. The operator owns the variable, so + the allowlist stays the operator's decision; an unset variable is an + error rather than a silently skipped entry. + """ + cfg = self._http_capability(fn) or {} + out: list[str] = [] + for entry in (cfg.get("allow") or []): + text = str(entry) + if "${" in text: + if self.resolve_ref is None: + raise SandboxError(f"cannot resolve {text!r}: no environment scope configured") + try: + text = self.resolve_ref(text) + except KeyError as exc: + raise SandboxError(f"http allowlist entry {entry!r} is unresolved: {exc}") from exc + out.append(text) + return out + + @staticmethod + def _host_allowed(host: str, allow: list[str]) -> bool: + return any(host == a or host.endswith("." + a.lstrip(".")) for a in allow) + + async def _check_http_url(self, fn, url: str) -> None: + cfg = self._http_capability(fn) + if cfg is None: + raise SandboxError("capability denied: this function does not declare the http capability") + parsed = httpx.URL(url) + if parsed.scheme not in ("http", "https"): + raise SandboxError(f"http step must use http(s), got {parsed.scheme!r}") + if parsed.userinfo: + raise SandboxError("http step must not embed credentials in the URL; use ${VAR} headers") + host = parsed.host or "" + allow = self._allowed_hosts(fn) + if not allow: + raise SandboxError("capability denied: http capability declares no allowed hosts") + if not self._host_allowed(host, allow): + raise SandboxError(f"capability denied: {host!r} is not in the http allowlist {allow}") + if self.http_allowlist and not self._host_allowed(host, list(self.http_allowlist)): + raise SandboxError(f"http denied by deployment allowlist: {host!r}") + + # --------------------------------------------------------------- execute + + async def run(self, session, fn, params: dict[str, Any]) -> Any: + from ..engine.service import assemble_row + + spec = fn.spec + steps: dict[str, Any] = {} + tpl = _Template(params, steps) + read_ok = self._read_capability(fn) + llm_cfg = self._llm_capability(fn) + llm_calls = 0 + llm_budget = _llm_budget(llm_cfg) + + for step in spec.steps: + if step.kind == "read": + if step.object not in read_ok: + raise SandboxError( + f"capability denied: read-objects does not include {step.object!r}" + ) + if self.compiled is None or step.object not in self.compiled.objects: + raise SandboxError(f"unknown object type {step.object!r}") + filt = tpl.render(step.filter) if step.filter else None + rows, _total = await self.repo.query( + session, step.object, filt=filt, limit=int(step.limit or 100), + ) + # the same assembly the sandbox's query RPC performs -- see the + # module docstring on why a function sees real values + steps[step.id] = [assemble_row(self.compiled, step.object, r) for r in rows] + elif step.kind == "llm": + if llm_cfg is None: + raise SandboxError("capability denied: this function does not declare the llm capability") + if llm_calls >= llm_budget: + raise SandboxError(f"llm call budget exhausted ({llm_budget})") + if self.llm is None: + raise SandboxError("platform LLM is not configured (set ONTOGENY_LLM_BASE_URL)") + llm_calls += 1 + prompt = tpl.render_text(step.prompt or "") + system = tpl.render_text(step.system) if step.system else None + model_override = (llm_cfg or {}).get("model") + client = self.llm + if model_override and model_override != getattr(self.llm, "model", None): + client = self.llm.scoped(str(model_override)) + out = await client.chat( + ([{"role": "system", "content": system}] if system else []) + + [{"role": "user", "content": prompt}] + ) + steps[step.id] = out.get("content") + elif step.kind == "http": + url = tpl.render_text(step.url or "") + if "${" in url and self.resolve_ref is not None: + url = self.resolve_ref(url) Review Comment: Good catch. The fix is to resolve `${VAR}` references from the deployment env BEFORE interpolating caller parameters, so the caller cannot introduce a `${...\}` token. I'll address this in a follow-up commit. ########## ontogeny/extensions/agent-builtin-llm/ontogeny_ext_agent_llm/engine.py: ########## @@ -0,0 +1,369 @@ +# Copyright 2026 Apache HugeGraph Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Builtin LLM engine (engine.kind = builtin-llm): the paradigm's reference +implementation of AgentEngine. + +A deliberately small loop — plan → tool → observe → … → final: + +1. build a prompt: task + effective tool catalog (JSON schema) + transcript +2. llm.chat → extract strict JSON: {"tool": name, "args": {...}} or {"final": str} +3. unparseable → re-ask with the error (max 2 retries), then give up cleanly +4. executor.call_tool(...) — the ONLY execution door; every gate lives in the + broker behind it +5. stop on final / pending_approval (blocked_on_approval) / budget outcome / + steps exhausted; every step lands in ontogeny_agent_step via the broker (I4) + +The executor is the transport detail (agent-paradigm §3): by default the +engine drives over MCP — the SAME protocol and tool surface external agents +use, on an in-memory session — so the MCP path is exercised in production +and in tests, never a privileged shortcut. When the optional ``mcp`` extra +is absent the engine degrades to in-process broker calls (identical gates, +same envelopes; only the transport differs). ``last_transport`` records +which one drove, for observability and tests. + +Session vs run: a `final` answer (or an LLM failure) ends the RUN, not the +SESSION -- the session returns to `open` with the round's result recorded, so +the next instruction drives it again. A session only terminates when its +budget threshold fires (budget_exhausted) or a human finishes it explicitly +(POST /finish); both can be re-opened by driving again (start_run). + +The LLM only ever chooses tools; facts come exclusively from tool results. +No memory, no RAG, no multi-agent — by design (agent-paradigm.md §0). +""" +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Any, Protocol + +from ontogeny.llm import LLMError, extract_json + +log = logging.getLogger("ontogeny.agent.engine") + +_MAX_RESULT_CHARS = 2000 +_MAX_REPAIRS = 2 +_MAX_TRANSCRIPT_CHARS = 60_000 + +_SYSTEM_PROMPT = """You drive an operational-ontology agent session. Each turn you answer \ +with STRICT JSON only — no prose, no code fences — choosing ONE action: + + {"thought": "<why>", "tool": "<tool name>", "args": {...}} to call a tool, or + {"thought": "<why>", "final": "<conclusion for the human>"} + +Rules: +- "thought" is REQUIRED and is shown to the human: one or two short sentences of \ +your actual reasoning — what you know so far, what is still missing, and why this \ +choice. Write it in the language of the task. Do not merely restate the tool call. +- Only tools from the provided catalog; args must match the tool's input_schema. +- Facts come exclusively from tool results. If the tools did not answer the task, \ +say so in "final" instead of inventing an answer. +- Writes may pause for human approval (outcome pending_approval); when you see one, \ +usually report it in "final" unless the task clearly requires more steps. +- Prefer finishing with a concise, factual "final" over redundant calls.""" + + +class ChatClient(Protocol): + """Structural type: satisfied by OllamaChat and by test stubs.""" + + async def chat(self, messages: list[dict[str, str]], **kw: Any) -> dict[str, Any]: ... + + +class ToolExecutor(Protocol): + """The transport seam: how the engine's tool calls reach the broker. + + ``mcp`` speaks the MCP protocol (dogfooding the external surface); + ``broker`` is the in-process fallback when the mcp extra is absent. + """ + + kind: str + + async def call_tool(self, session_id: int, tool: str, args: dict[str, Any], + *, thought: str = "") -> dict[str, Any]: ... + + +class McpExecutor: + """Drives the session's tools over MCP (in-memory client session).""" + + kind = "mcp" + + def __init__(self, client) -> None: + self.client = client + + async def call_tool(self, session_id: int, tool: str, args: dict[str, Any], + *, thought: str = "") -> dict[str, Any]: + out = await self.client.call_tool(tool, {**args, "session_id": session_id, + "thought": thought}) + return out + + +class BrokerExecutor: + """In-process fallback: the broker door without the MCP transport.""" + + kind = "broker" + + def __init__(self, broker) -> None: + self.broker = broker + + async def call_tool(self, session_id: int, tool: str, args: dict[str, Any], + *, thought: str = "") -> dict[str, Any]: + return await self.broker.call_tool(session_id, tool, args, + engine=True, thought=thought) + + +@dataclass +class EngineResult: + status: str # the RUN's outcome: finished | blocked_on_approval | budget_exhausted + result: dict[str, Any] + + +class BuiltinLlmEngine: + def __init__(self, sc, llm: ChatClient | None, + *, executor: ToolExecutor | None = None) -> None: + """``sc`` is the ServiceContext (broker for session bookkeeping, and + the MCP surface when the extra is installed). Tests may pin an + ``executor``; without one the engine prefers MCP and falls back.""" + self.sc = sc + self.broker = sc.agents + self.llm = llm + self._pinned = executor + self.last_transport: str | None = None + + async def _open_executor(self, stack) -> ToolExecutor: + """Prefer the MCP transport; degrade to in-process broker calls when + the optional extra is absent (governance identical, transport not).""" + if self._pinned is not None: + return self._pinned + try: + from ontogeny.mcp_server import in_process_mcp_session + + client = await stack.enter_async_context(in_process_mcp_session(self.sc)) + return McpExecutor(client) + except Exception: # noqa: BLE001 -- missing extra: degrade, don't die + log.warning("builtin-llm: mcp extra not available, engine falls back " + "to in-process broker calls (install 'ontogeny[mcp]')") + return BrokerExecutor(self.broker) + + async def drive(self, session_id: int, *, resume: bool = False) -> EngineResult: + """Drive without an LLM is a configuration error; the run endpoint + surfaces it as 503 LLM_NOT_CONFIGURED (checked before start).""" + if self.llm is None: + raise RuntimeError("LLM_NOT_CONFIGURED") + + session = await self.broker.get_session(session_id) + task = session.get("task") or "Complete the task." + tools: dict[str, Any] = { + name: {"name": t["name"], "kind": t["kind"], "target": t.get("target"), + "description": t["description"], "writes": t["writes"], + "input_schema": t.get("input_schema") or {}} + for name, t in (session.get("tools") or {}).items() + } + from contextlib import AsyncExitStack + + async with AsyncExitStack() as stack: + executor = await self._open_executor(stack) + self.last_transport = executor.kind + return await self._drive_loop(session_id, task, tools, executor) + + async def _drive_loop(self, session_id: int, task: str, tools: dict[str, Any], + executor: ToolExecutor) -> EngineResult: + await self.broker.set_status(session_id, "running") + + repairs = 0 + # a dying engine must still release the session into a terminal state + try: + while True: + fresh = await self.broker.get_session(session_id) + budget = fresh["budget"] + # steps == 0 means UNLIMITED (a session opened with explicit + # "no budget"): the gate fires only when a positive cap exists + if budget["steps"] and budget["steps_used"] >= budget["steps"]: + await self.broker.set_status(session_id, "budget_exhausted", + {"error": "step budget exhausted"}) + return EngineResult("budget_exhausted", {"error": "step budget exhausted"}) + + transcript = self._transcript(fresh.get("steps") or []) + messages = self._prompt(task, tools, transcript) + try: + decision = await self._decide(messages, tools) + except LLMError as exc: + # an LLM failure ends the run, not the session: keep the + # session open with the error recorded, so the operator can + # re-drive it (or end it by hand) instead of losing it + await self.broker.set_status( + session_id, "open", {"error": f"llm: {exc.message}"}) + return EngineResult("finished", {"error": f"llm: {exc.message}"}) + + # the model's own reasoning for this turn; shown to the human in + # the step timeline and fed back so the loop stays coherent + thought = str(decision.get("thought") or "").strip() + + if "final" in decision: + final = str(decision["final"]) + # a final answer ends THIS RUN, not the session: the session + # returns to `open` carrying the round's conclusion, ready + # for the next instruction. Only the budget threshold or an + # explicit human finish terminates a session. + await self.broker.set_status( + session_id, "open", + {"final": final, **({"thought": thought} if thought else {})}) + return EngineResult("finished", {"final": final}) + + tool = str(decision.get("tool") or "") + args = decision.get("args") or {} + out = await executor.call_tool(session_id, tool, args, thought=thought) + outcome = str(out.get("outcome")) + + if outcome in ("AGENT_TOOL_NOT_ALLOWED", "NOT_FOUND", "MCP_TOOL_ERROR"): + repairs += 1 + if repairs > _MAX_REPAIRS: + # give up on the RUN, keep the session drivable: the + # error is recorded and a human can re-steer or finish + await self.broker.set_status( + session_id, "open", {"error": f"engine gave up: {outcome}"}) + return EngineResult("finished", {"error": f"engine gave up: {outcome}"}) + continue # the failed call is in the transcript; model self-corrects + if outcome == "AGENT_BUDGET_EXCEEDED": + await self.broker.set_status(session_id, "budget_exhausted", + {"error": out.get("error")}) + return EngineResult("budget_exhausted", {"error": out.get("error")}) + if outcome == "pending_approval": + await self.broker.set_status( + session_id, "blocked_on_approval", + {"blocked_on": out.get("approval_id"), + "message": "write is awaiting human approval; approve, then run again"}) + return EngineResult("blocked_on_approval", + {"approval_id": out.get("approval_id")}) + if outcome == "AGENT_SESSION_CLOSED": Review Comment: Good catch. The engine's outcome handling should whitelist-ok outcomes and terminate on `AGENT_SESSION_EXPIRED`. I'll fix the engine loop and add a hard step cap as a belt. ########## ontogeny/server/ontogeny/agent/broker.py: ########## @@ -0,0 +1,727 @@ +# Copyright 2026 Apache HugeGraph Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""AgentBroker: session lifecycle + governed tool execution. + +Every external agent call funnels through :meth:`call_tool`. The method is +the contract: authenticate the session, check the catalog, execute through +ServiceContext, record the step. Writes additionally go through the approval +gate (M2) -- a pending approval row is created and nothing executes until a +human consumes it. +""" +from __future__ import annotations + +import datetime as _dt +import logging +import time +from typing import Any + +from sqlalchemy import delete, or_, select, update + +from ..action.models import OutboxRow # noqa: F401 (re-exported for API layer typing) +from ..errors import NotFoundError, OOError, PolicyDeniedError, StoreError +from ..registry.compiled import CompiledOntology +from .catalog import ToolDef, build_catalog, catalog_hash, effective_tools +from .models import AgentApprovalRow, AgentSessionRow, AgentStepRow, args_digest + +log = logging.getLogger("ontogeny.agent") + + +def _utcnow() -> _dt.datetime: + return _dt.datetime.now(_dt.timezone.utc) + + +def _jsonable(value: Any) -> Any: + """Row objects carry dates/datetimes/Decimals; JSON columns refuse them.""" + if isinstance(value, (_dt.datetime, _dt.date)): + return value.isoformat() + if isinstance(value, dict): + return {k: _jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(v) for v in value] + try: + import json as _json + _json.dumps(value) + return value + except (TypeError, ValueError): + return str(value) + + +class SessionBudgetExceeded(OOError): + code = "AGENT_BUDGET_EXCEEDED" + http_status = 429 + + +class ToolNotAllowed(OOError): + code = "AGENT_TOOL_NOT_ALLOWED" + http_status = 403 + + +class AgentServiceError(StoreError): + """Raised for broker-internal problems (bad session state etc.).""" + + +class AgentBroker: + def __init__(self, sc) -> None: + self.sc = sc + + # ------------------------------------------------------------- catalog + + def plugin(self, name: str) -> Any: + if self.sc.compiled is None: + raise StoreError("service not initialized") + plug = self.sc.compiled.agent_plugins.get(name) + if plug is None: + raise NotFoundError(f"unknown agent plugin {name!r}") + return plug + + def list_plugins(self) -> list[dict[str, Any]]: + if self.sc.compiled is None: + return [] + out = [] + for name, plug in self.sc.compiled.agent_plugins.items(): + tools = effective_tools(self.sc.compiled, plug) + out.append({ + "name": name, + "display": plug.metadata.display, + "description": plug.metadata.description, + "transport": plug.spec.transport.kind, + "engine": {"kind": plug.spec.engine.kind, "endpoint": plug.spec.engine.endpoint}, + "principal": plug.spec.principal.as_principal(), + "approval": plug.spec.approval.writes, + "budget": { + "steps": plug.spec.budget.steps, + "wall_ms": plug.spec.budget.wall_ms, + "writes_per_session": plug.spec.budget.writes_per_session, + }, + "tools": {n: t.to_meta() for n, t in tools.items()}, + }) + return out + + # ------------------------------------------------------------ sessions + + async def open_session(self, plugin_name: str, task: str, *, + budget: dict[str, Any] | None = None, + expires_at: _dt.datetime | None = None, + driver: dict[str, Any] | None = None) -> dict[str, Any]: + """Open a session. ``budget`` overrides the plugin's frozen limits per + field; 0 (or a negative) means UNLIMITED, an omitted field keeps the + plugin's value. ``expires_at`` bounds the session's usable life. + ``driver`` is the accountable WHO behind the session (the plugin + principal stays the EXECUTION identity; this records who asked for it). + """ + plug = self.plugin(plugin_name) + compiled: CompiledOntology = self.sc.compiled # type: ignore[assignment] + tools = effective_tools(compiled, plug) + budget = budget or {} + + def _limit(field: str, plugin_default: int) -> int: + v = budget.get(field) + if v is None: + return plugin_default + return max(int(v), 0) # 0 = unlimited Review Comment: You're right — `_limit` returns `max(plugin_default, caller_value)` which can only widen. The fix is to clamp the override to the plugin's own budget ceiling. I'll address in a follow-up. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
