simon824 commented on code in PR #380:
URL: https://github.com/apache/hugegraph-ai/pull/380#discussion_r4336328583


##########
.github/workflows/third-party-smoke.yml:
##########
@@ -0,0 +1,28 @@
+# M0 smoke gate for the third_party/ subtree (docs/hugegraph-ai-merge-plan.md 
§1.4):
+# deliberately tiny — it only proves the vendored upstream tree is still 
present
+# with its license and provenance intact. Upstream tests do NOT run here (they
+# need a HugeGraph server / torch; the default gate stays fast).
+name: third-party-smoke
+
+on:
+  push:
+    branches: ["**"]
+  pull_request:
+
+jobs:
+  smoke:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - name: third_party present, licensed, registered
+        run: |
+          test -d third_party/hugegraph-llm
+          test -d third_party/hugegraph-ml
+          test -d third_party/hugegraph-python-client
+          test -f third_party/LICENSE
+          test -f THIRD_PARTY.md
+          grep -q "hugegraph-ai" NOTICE
+      - name: zero import coupling (merge-plan §1.7)
+        run: |
+          ! git grep -n "hugegraph_llm" -- server extensions tests

Review Comment:
   Fixed: the zero-coupling gate now uses an explicit `if git grep ...; then 
exit 1; fi` (ontogeny-ci.yml, commit b8a9965).



##########
server/ontogeny/__init__.py:
##########
@@ -0,0 +1,24 @@
+# Copyright 2026 Apache HugeGraph Authors

Review Comment:
   Fixed: all ~304 module files now carry the ASF source header (commit 
5f8896b); CI gate enforces presence.



##########
NOTICE:
##########
@@ -1,5 +1,20 @@
-Apache HugeGraph AI
-Copyright 2022-2026 The Apache Software Foundation
+ontogeny

Review Comment:
   Fixed: NOTICE now starts with the upstream Apache HugeGraph AI identity; the 
module attribution is appended (commit b8a9965).



##########
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):

Review Comment:
   Fixed: `forbid` statements now follow Cedar precedence — a matching forbid 
overrides any permit (engine + publish-time POLICY-FORBID error for unparseable 
ones). See commit 203f271.



##########
ontogeny/server/ontogeny/engine/service.py:
##########
@@ -0,0 +1,348 @@
+# 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.
+"""Query service: object assembly (derived properties + masking), link
+expansion, aggregation and multi-hop graph traversal (SQL-recursive default;
+the HugeGraph projection is an accelerator behind the same API).
+"""
+from __future__ import annotations
+
+import time
+from typing import Any
+
+from ..core import expr as _expr
+from ..core.models import ForeignKeyJoin
+from ..errors import NotFoundError, StoreError
+from ..policy.engine import PolicyEngine
+from ..stores.repo import ObjectRepository
+
+
+def assemble_row(compiled, object_type: str, props: dict) -> dict:
+    """Row props -> object props with derived values computed.
+
+    Shared by the query API and the function sandbox: functions read the same
+    shape of object a human would, so a derived property is never invisible to
+    business logic (it used to read as None/0 inside the sandbox).
+    """
+    obj = compiled.objects.get(object_type)
+    if obj is None:
+        raise NotFoundError(f"unknown object type {object_type!r}")
+    out = dict(props)
+    ctx = _expr.ExprContext(target=props)
+    for prop, pdef in obj.spec.properties.items():
+        # only expression deriveds are computed at read time; function deriveds
+        # are materialized columns maintained by the derivation worker
+        if pdef.derived_kind() == "expr":
+            try:
+                out[prop] = _expr.evaluate(pdef.derived, ctx)

Review Comment:
   Fixed: publish-time `DERIVED-MARKING` validator error — a derived property 
reading a marked property must declare an equal-or-stricter marking. Commit 
203f271.



##########
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
+
+        async with self.sc.sessionmaker() as session:
+            row = AgentSessionRow(
+                plugin=plugin_name,
+                principal=plug.spec.principal.id,
+                task=task or "",
+                budget_steps=_limit("steps", plug.spec.budget.steps),
+                budget_wall_ms=_limit("wall_ms", plug.spec.budget.wall_ms),
+                budget_writes=_limit("writes_per_session", 
plug.spec.budget.writes_per_session),
+                expires_at=expires_at,
+                catalog_hash=catalog_hash(tools),
+                driver={"id": driver.get("id", "?"), "via": driver.get("via", 
"?")}
+                if driver else None,
+                run_started_at=_utcnow(),
+                # freeze the catalog: a plugin edit mid-session can neither
+                # widen nor narrow a running session (the documented contract)
+                tools_json={n: t.to_meta() for n, t in tools.items()},
+            )
+            session.add(row)
+            await session.commit()
+            return self._session_view(row, tools)
+
+    async def start_run(self, session_id: int, task: str | None = None) -> 
dict[str, Any]:
+        """Direction 3: a session may be driven MANY times. Each run bumps the
+        run counter (new steps are tagged with it) and re-opens a finished or
+        budget-exhausted session so the engine can drive it again. ``task`` is
+        this run's instruction: when given, it becomes the session's task (the
+        creation-time text is a description, not the command)."""
+        async with self.sc.sessionmaker() as session:
+            row = (await session.execute(
+                select(AgentSessionRow).where(AgentSessionRow.id == session_id)
+            )).scalar_one_or_none()
+            if row is None:
+                raise NotFoundError(f"agent session {session_id} not found")
+            if self._check_expired(row):
+                raise OOError("agent session expired",
+                              details={"code": "AGENT_SESSION_EXPIRED"})
+            if row.status == "running":
+                return {"id": row.id, "status": "running", "run_count": 
row.run_count}
+            if task and task.strip():
+                row.task = task.strip()
+            row.status = "open"
+            row.finished_at = None
+            row.run_count += 1
+            # a fresh run gets a fresh wall clock: wall_ms bounds one drive,
+            # not the session's whole multi-run life
+            row.run_started_at = _utcnow()
+            await session.commit()
+            return {"id": row.id, "status": row.status, "run_count": 
row.run_count}
+
+    def _check_expired(self, row: AgentSessionRow) -> bool:
+        """True when the session is past its expires_at (and marks it)."""
+        # SQLite returns naive UTC datetimes; normalize before comparing
+        exp = row.expires_at.replace(tzinfo=_dt.timezone.utc) \
+            if row.expires_at is not None and row.expires_at.tzinfo is None \
+            else row.expires_at
+        if exp is not None and _utcnow() > exp:
+            row.status = "expired"
+            return True
+        return False
+
+    async def get_session(self, session_id: int) -> dict[str, Any]:
+        async with self.sc.sessionmaker() as session:
+            row = (await session.execute(
+                select(AgentSessionRow).where(AgentSessionRow.id == session_id)
+            )).scalar_one_or_none()
+            if row is None:
+                raise NotFoundError(f"agent session {session_id} not found")
+            steps = (await session.execute(
+                select(AgentStepRow).where(AgentStepRow.session_id == 
session_id)
+                .order_by(AgentStepRow.seq)
+            )).scalars().all()
+            plug = self.sc.compiled.agent_plugins.get(row.plugin) if 
self.sc.compiled else None
+            tools = self._frozen_tools(row, plug)
+            return self._session_view(row, tools, steps=[self._step_view(s) 
for s in steps])
+
+    async def list_sessions(self, *, plugin: str | None = None, limit: int = 
50) -> list[dict[str, Any]]:
+        stmt = 
select(AgentSessionRow).order_by(AgentSessionRow.id.desc()).limit(limit)
+        if plugin:
+            stmt = stmt.where(AgentSessionRow.plugin == plugin)
+        async with self.sc.sessionmaker() as session:
+            rows = (await session.execute(stmt)).scalars().all()
+        return [self._session_view(r) for r in rows]
+
+    async def delete_session(self, session_id: int) -> dict[str, Any]:
+        """Delete one agent session and every observation belonging to it.
+
+        The session row is the governance record's root: steps and approvals
+        are meaningless without it, so the delete is deliberately transitive.
+        """
+        async with self.sc.sessionmaker() as session:
+            row = await session.get(AgentSessionRow, session_id)
+            if row is None:
+                raise NotFoundError(f"agent session {session_id} not found")
+            await 
session.execute(delete(AgentStepRow).where(AgentStepRow.session_id == 
session_id))
+            await 
session.execute(delete(AgentApprovalRow).where(AgentApprovalRow.session_id == 
session_id))

Review Comment:
   Fixed: sessions carrying approval records are not deletable via the API; 
sessions without records may only be deleted by their own driver or an admin. 
Commit 203f271.



-- 
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]

Reply via email to