From ad3f8ae07ecd397571a86d64827417f48249a366 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 19 Jun 2026 15:31:47 +0200 Subject: [PATCH 1/4] feat(demo): hr agent definition and ui --- demo/agent-server/pyproject.toml | 9 +- .../src/agent_server/agents/hr.py | 64 ++++ .../src/agent_server/agents/hr_agent.py | 282 ++++++++++++++++++ .../src/agent_server/agents/hr_state.py | 46 +++ .../src/agent_server/agents/itsm_agent.py | 85 +----- .../agent_server/agents/langchain_events.py | 81 +++++ .../src/agent_server/agents/select.py | 5 + demo/agent-server/src/agent_server/roster.py | 11 + demo/agent-server/src/agent_server/ui/hr.yaml | 30 ++ 9 files changed, 530 insertions(+), 83 deletions(-) create mode 100644 demo/agent-server/src/agent_server/agents/hr.py create mode 100644 demo/agent-server/src/agent_server/agents/hr_agent.py create mode 100644 demo/agent-server/src/agent_server/agents/hr_state.py create mode 100644 demo/agent-server/src/agent_server/agents/langchain_events.py create mode 100644 demo/agent-server/src/agent_server/ui/hr.yaml diff --git a/demo/agent-server/pyproject.toml b/demo/agent-server/pyproject.toml index 0de560a..8311177 100644 --- a/demo/agent-server/pyproject.toml +++ b/demo/agent-server/pyproject.toml @@ -17,11 +17,12 @@ llm = [ "google-adk>=1.0", # DevOps (google-adk runtime) "litellm>=1.40", # DevOps model backend (openai/* via Google ADK) ] -# ITSM (deepagents/LangChain). Heavier stack, so kept out of `llm` — pulled in -# alongside hexgate by `make install-hexgate` (the ITSM agent is gated anyway). +# LangChain agents (ITSM + HR). Heavier stack, so kept out of `llm` — pulled in +# alongside hexgate by `make install-hexgate` (these agents are gated anyway). itsm = [ - "langchain-openai>=0.2", # ITSM model backend (ChatOpenAI) - "langchain-core>=0.3", # ITSM tools (@tool) + "langchain-openai>=0.2", # model backend (ChatOpenAI) + "langchain-core>=0.3", # tools (@tool) + "langgraph>=0.2", # HR agent (create_react_agent) "deepagents>=0.0.5", # ITSM agent (create_deep_agent over LangGraph) ] dev = [ diff --git a/demo/agent-server/src/agent_server/agents/hr.py b/demo/agent-server/src/agent_server/agents/hr.py new file mode 100644 index 0000000..47f9af3 --- /dev/null +++ b/demo/agent-server/src/agent_server/agents/hr.py @@ -0,0 +1,64 @@ +"""HexaUI contract wrapper for the HR (RH) agent. + +Resolves the OpenAI key, picks the plain or HexGate-gated path, and projects each +LangChain event into a native event. The agent lives in ``hr_agent``; the caller's +``role`` (default < manager < gestionnaire_rh) flips each policy decision. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import AsyncIterator +from typing import Any + +from .. import protocol + +logger = logging.getLogger("agent_server.hr") + +# The elevated HR roles (hr_policy.yaml). Anything else — no role, or a role from +# another agent (nurse, viewer, requester…) — normalizes to the `default` +# baseline rather than passing an unrecognized string to the policy. +_HR_ROLES = {"manager", "gestionnaire_rh"} + + +class HrAgent: + framework = "langchain" + + async def run( + self, *, input: dict[str, Any], context: dict[str, Any] + ) -> AsyncIterator[dict]: + # ChatOpenAI (the model backend) reads OPENAI_API_KEY from the env. + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + yield protocol.error( + "No OpenAI API key available. Set OPENAI_API_KEY in the " + "agent-server .env (or the process environment)." + ) + return + + # Lazy import so a missing langchain/hexgate install doesn't break the + # roster — only picking the HR agent pays the import cost. + from . import hr_agent + + # HexGate-gated whenever configured; plain graph otherwise. + if os.getenv("HEXGATE_KEY"): + # `name` / `role` ride in `context.user` (CONTRACT.md §5); fall back to + # a static identity for standalone runs that send no user block. + caller = protocol.caller(context) + identity = caller.get("name") or "hexui-demo" + role = caller.get("role") or os.getenv("HEXGATE_ROLE", "default") + if role not in _HR_ROLES: + role = "default" + events = hr_agent.stream_as(input, user_id=identity, role=role) + else: + events = hr_agent.stream(input) + + try: + async for event in events: + native_event = hr_agent.to_native_event(event) + if native_event is not None: + yield native_event + except Exception as exception: # noqa: BLE001 — degrade to a visible error event + logger.exception("hr run failed") + yield protocol.error(f"agent failed: {exception}") diff --git a/demo/agent-server/src/agent_server/agents/hr_agent.py b/demo/agent-server/src/agent_server/agents/hr_agent.py new file mode 100644 index 0000000..aa33dd3 --- /dev/null +++ b/demo/agent-server/src/agent_server/agents/hr_agent.py @@ -0,0 +1,282 @@ +"""HR (RH) assistant agent — RBAC with field-level scoping (LangChain). + +Vendored from ``hexgate/examples/hr_agent.py``; the HexaUI wrapper is ``hr.py``. +One agent definition — the caller's ROLE flips every decision via the platform +policy (resolved by agent name ``hr_agent``; source in +``hexgate/examples/hr_policy.yaml``). The escalation ladder, least → most +privileged: ``default < manager < gestionnaire_rh``. + +The policy does the gating on each tool call: + + - field-level scoping on one read tool — ``get_employee_data`` takes a ``field`` + arg, and each role widens the allowed ``args.field`` allowlist; + - the ultra-sensitive medical read is its own tool (``get_medical_leave``) so it + gates separately; salary writes, the aggregated payroll view, and offboarding + are gestionnaire_rh-only; ``export_payroll`` is volume-capped (``args.count``) + so a prompt-injected agent can't siphon the whole payroll. + +The tools are stubs (no datastore) — the demo is about the policy, not the data. +``stream`` / ``stream_as`` yield LangChain ``astream_events`` items the proxy's +LangChain translator normalizes (projection shared via ``langchain_events``). +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +from dotenv import load_dotenv +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI + +from . import hr_state +from .langchain_events import messages_input, to_native_event # noqa: F401 + +# Load .env at import — the eager `agent` below (which `hexgate register` +# resolves) needs OPENAI_API_KEY at ChatOpenAI construction time. +load_dotenv() + + +def _actor() -> str: + """The calling employee's NAME from the active User scope (the self-service + tools act on this, never a model-supplied id). Falls back to a demo identity + on the ungated path, where no User scope is set.""" + from hexgate.runtime import get_current_user + + user = get_current_user() + return user.user_id if user is not None else "hexui-demo" + + +# --------------------------------------------------------------------------- +# Tools — stubs. The constraint engine only sees the call's `args`; any check it +# can't express (row-level "son équipe" scope, name → id resolution) belongs in +# the body, keyed off the trusted User identity rather than a model-supplied arg. +# --------------------------------------------------------------------------- + + +@tool +def search_directory(name: str) -> str: + """Look up an employee in the internal directory by name. + + Returns non-sensitive annuaire fields only (title, department, manager, + work email). Never returns salary, bank, or medical data. + """ + return ( + f"(stub) annuaire — {name}: id=E1042, Responsable Marketing, " + f"service Marketing, manager=Paul Durand, " + f"work_email={name.split()[0].lower()}@acme.example" + ) + + +@tool +def get_employee_data(employee_id: str, field: str) -> str: + """Return a single FIELD of an employee record. + + `field` is one of: title, department, manager, work_email, leave_balance, + performance_rating, salary, contract, bank_account. Which fields a caller + may read is decided by the policy from their role — pass the field the + user asked for and let the policy gate it. Health data is NOT available + here; use get_medical_leave for that. + """ + sample = { + "title": "Responsable Marketing", + "department": "Marketing", + "manager": "Paul Durand", + "work_email": "sophie.martin@acme.example", + "leave_balance": "18.5 jours", + "performance_rating": "3,8 / 5 (cycle 2025)", + "salary": "54 000 € brut/an", + "contract": "CDI, temps plein, depuis 2021-03-01", + "bank_account": "FR76 3000 4000 0512 3456 7890 143", + } + value = sample.get(field, f"") + return f"(stub) employee {employee_id} — {field}: {value}" + + +@tool +def get_medical_leave(employee_id: str) -> str: + """Return current medical / sick-leave information for an employee. + + Health data is the most sensitive category (RGPD). It lives in its own + tool on purpose so it can be gated separately from the rest of the + employee record. + """ + return ( + f"(stub) employee {employee_id}: arrêt maladie en cours du " + f"2026-06-02 au 2026-06-27 (motif non communiqué)." + ) + + +@tool +def update_salary(employee_id: str, new_amount: float) -> str: + """Set an employee's gross annual salary to `new_amount` (in euros).""" + return ( + f"(stub) salaire de l'employé {employee_id} mis à jour → " + f"{new_amount:,.0f} € brut/an" + ) + + +@tool +def view_compensation(team: str) -> str: + """Return the aggregated compensation grid / payroll mass for a team.""" + return ( + f"(stub) équipe {team}: masse salariale 1 245 000 €/an, " + f"effectif 17, salaire médian 58 000 €, P90 92 000 €." + ) + + +@tool +def export_payroll(period: str, count: int) -> str: + """Export `count` payslips for the given `period` (e.g. '2026-01'). + + Pass `count` = the number of payslips the export would produce. The + policy caps this per role so a runaway request can't dump the whole + company's payroll. + """ + return f"(stub) export de {count} bulletins de paie pour {period} → PAY-EXP-3391" + + +@tool +def offboard_employee(employee_id: str) -> str: + """Trigger the offboarding (departure) procedure for an employee.""" + return ( + f"(stub) procédure de départ déclenchée pour l'employé " + f"{employee_id} → OFFB-2207" + ) + + +# ── Self-service (the calling employee acts on their OWN record) ───────────── +# These take no employee_id — they operate on the trusted caller identity +# (`_actor`), so an employee can only ever see/request against themselves. + + +@tool +def get_my_leave_balance() -> str: + """Return the calling employee's own remaining leave balance, in days.""" + name = _actor() + balance = hr_state.leave_balance(name) + days = ", ".join(f"{kind}: {amount} j" for kind, amount in balance.items()) + return f"(stub) {name} — solde de congés → {days}" + + +@tool +def request_time_off(start_date: str, end_date: str, leave_type: str = "conges_payes") -> str: + """Submit a time-off request for the calling employee. + + `start_date` / `end_date` are ISO dates (YYYY-MM-DD); `leave_type` is one of + conges_payes, rtt, sick, unpaid. The request is recorded as 'pending'. + """ + name = _actor() + request = hr_state.add_request( + name, start_date=start_date, end_date=end_date, leave_type=leave_type + ) + return ( + f"(stub) demande {request['id']} créée pour {name} — {leave_type} " + f"du {start_date} au {end_date} (statut: {request['status']})." + ) + + +@tool +def list_my_time_off() -> str: + """List the calling employee's own time-off requests and their status.""" + name = _actor() + requests = hr_state.list_requests(name) + if not requests: + return f"(stub) aucune demande de congés pour {name}." + lines = [ + f"{r['id']} [{r['status']}] {r['type']} {r['start']} → {r['end']}" + for r in requests + ] + return f"(stub) demandes de {name}:\n" + "\n".join(lines) + + +TOOLS = [ + search_directory, + get_employee_data, + get_medical_leave, + update_salary, + view_compensation, + export_payroll, + offboard_employee, + # self-service + get_my_leave_balance, + request_time_off, + list_my_time_off, +] + +INSTRUCTIONS = ( + "Tu es un assistant RH. Tu aides le personnel autorisé à consulter " + "l'annuaire, lire les champs d'un dossier salarié, modifier une " + "rémunération, exporter des bulletins de paie, consulter la compensation " + "agrégée et déclencher un offboarding. Quand l'utilisateur demande un " + "champ précis d'un dossier, appelle get_employee_data avec ce champ exact " + "(title, department, manager, work_email, leave_balance, " + "performance_rating, salary, contract, bank_account) ; pour les arrêts " + "maladie, utilise get_medical_leave. Si l'utilisateur désigne un salarié " + "par son nom, passe ce nom directement comme employee_id. Pour un export, " + "estime le nombre de bulletins (count) à partir de la demande. Pour les " + "demandes en libre-service de l'utilisateur sur SON propre dossier — solde " + "de congés, poser des congés, suivre ses demandes — utilise " + "get_my_leave_balance, request_time_off et list_my_time_off (ces outils " + "agissent sur l'appelant, ne demande pas d'identifiant). Tu n'as pas " + "besoin de demander confirmation avant d'appeler un outil — agis " + "directement sur les détails fournis. Utilise toujours un outil pour toute " + "consultation ou action RH, et fonde-toi uniquement sur le résultat des " + "outils comme source de vérité, jamais sur l'historique de la conversation. " + "La couche de politique gate les actions sensibles, fais-lui confiance pour " + "bloquer ce qui n'est pas autorisé. Réponds toujours dans la langue du " + "message de l'utilisateur." +) + + +# Built at import so `hexgate register` can resolve `…hr_agent:agent`; +# `stream_as` wraps it with HexGate policy enforcement at call time. +def _build_agent() -> Any: + from langgraph.prebuilt import create_react_agent + + built = create_react_agent( + model=ChatOpenAI(model="gpt-4o-mini", temperature=0), + tools=TOOLS, + prompt=INSTRUCTIONS, + ) + built.name = "hr_agent" # policy + manifest resolve by this name on the platform + return built + + +agent = _build_agent() + +# Enforced wrapper, built once on first gated use — `wrap_langchain_agent` +# mutates TOOLS in place, so re-running it per request would re-wrap them. +# One wrapper serves all users; `user` is passed per call. +_enforced: Any | None = None + + +def _enforced_agent() -> Any: + global _enforced + if _enforced is None: + from hexgate.adapters.langchain import wrap_langchain_agent + + _enforced = wrap_langchain_agent(agent=agent, tools=TOOLS) + return _enforced + + +# Invocation — yield LangChain astream_events items for the proxy (event +# projection + input coercion shared with the ITSM agent in `langchain_events`). + + +async def stream(input: Any) -> AsyncIterator[Any]: + """Stream the plain (ungated) graph, yielding astream_events items.""" + async for event in agent.astream_events(messages_input(input), version="v2"): + yield event + + +async def stream_as(input: Any, *, user_id: str, role: str) -> AsyncIterator[Any]: + """Same as :func:`stream`, but policy-gated against the caller — ``role`` + (default < manager < gestionnaire_rh) flips each decision.""" + from hexgate.runtime import User + + user = User(user_id=user_id, role=role, session_id="hexui-demo-hr") + async for event in _enforced_agent().astream_events( + messages_input(input), user=user + ): + yield event diff --git a/demo/agent-server/src/agent_server/agents/hr_state.py b/demo/agent-server/src/agent_server/agents/hr_state.py new file mode 100644 index 0000000..2a96171 --- /dev/null +++ b/demo/agent-server/src/agent_server/agents/hr_state.py @@ -0,0 +1,46 @@ +"""Minimal in-memory store for the HR self-service time-off tools. + +Process-global, single-process — fine for the demo (like ``devops_state`` / +``itsm_db``). Keyed by the caller's name so each employee only ever sees their +own balance and requests. Pending requests don't deduct the balance (there's no +approval step in this cut). +""" + +from __future__ import annotations + +from typing import Any + +# Default annual allowance every employee starts with, in days. +_DEFAULT_BALANCE = {"conges_payes": 25.0, "rtt": 12.0, "sick": 5.0} + +# name -> list of their time-off requests +_REQUESTS: dict[str, list[dict[str, Any]]] = {} +_counter = 0 + + +def leave_balance(name: str) -> dict[str, float]: + """Remaining leave (days) per type for `name`.""" + return dict(_DEFAULT_BALANCE) + + +def add_request( + name: str, *, start_date: str, end_date: str, leave_type: str +) -> dict[str, Any]: + """Record a new pending time-off request for `name`.""" + global _counter + _counter += 1 + request = { + "id": f"LEAVE-{_counter:04d}", + "employee": name, + "type": leave_type, + "start": start_date, + "end": end_date, + "status": "pending", + } + _REQUESTS.setdefault(name, []).append(request) + return request + + +def list_requests(name: str) -> list[dict[str, Any]]: + """Every time-off request `name` has submitted.""" + return _REQUESTS.get(name, []) diff --git a/demo/agent-server/src/agent_server/agents/itsm_agent.py b/demo/agent-server/src/agent_server/agents/itsm_agent.py index fc1dc54..9907296 100644 --- a/demo/agent-server/src/agent_server/agents/itsm_agent.py +++ b/demo/agent-server/src/agent_server/agents/itsm_agent.py @@ -305,22 +305,15 @@ def _enforced_agent() -> Any: return _enforced -# Invocation — yield LangChain astream_events items for the proxy. - - -def _messages_input(input: Any) -> dict[str, Any]: - """Coerce the contract input into the ``{"messages": [...]}`` LangGraph wants.""" - messages = (input or {}).get("messages") if isinstance(input, dict) else None - if isinstance(messages, list) and messages: - return {"messages": messages} - from .. import protocol - - return {"messages": [{"role": "user", "content": protocol.last_user_text(input)}]} +# Invocation — yield LangChain astream_events items for the proxy. The event +# projection + input coercion are shared with the HR agent in `langchain_events`; +# `to_native_event` is re-exported here so `itsm.py` can call it off this module. +from .langchain_events import messages_input, to_native_event # noqa: E402, F401 async def stream(input: Any) -> AsyncIterator[Any]: """Stream the plain (ungated) deepagent graph, yielding astream_events items.""" - async for event in agent.astream_events(_messages_input(input), version="v2"): + async for event in agent.astream_events(messages_input(input), version="v2"): yield event @@ -332,72 +325,6 @@ async def stream_as(input: Any, *, user_id: str, role: str) -> AsyncIterator[Any user = User(user_id=user_id, role=role, session_id="hexui-demo-itsm") async for event in _enforced_agent().astream_events( - _messages_input(input), user=user + messages_input(input), user=user ): yield event - - -# LangChain astream_events → JSON-serializable native event. astream_events -# yields live objects (AIMessageChunk, ToolMessage, …) the agent-server's -# json.dumps framing can't serialize, so project each down to the plain wire -# shape the proxy's translator reads; ignored events (on_chain_*, …) are dropped. - - -def _text_of(obj: Any) -> str: - """Pull printable text from a LangChain message/chunk (or dict/str).""" - content = getattr(obj, "content", None) - if content is None and isinstance(obj, dict): - content = obj.get("content") - if isinstance(content, str): - return content - if isinstance(content, list): - parts = [ - p["text"] - for p in content - if isinstance(p, dict) and isinstance(p.get("text"), str) - ] - return "".join(parts) - return obj if isinstance(obj, str) else "" - - -def _jsonable(value: Any) -> Any: - """Best-effort JSON-safe coercion for tool-call arguments.""" - import json - - try: - json.dumps(value) - return value - except TypeError: - if isinstance(value, dict): - return {k: _jsonable(v) for k, v in value.items()} - return str(value) - - -def to_native_event(event: dict[str, Any]) -> dict | None: - """Project one LangChain ``astream_events`` item into a JSON-safe native - event for the proxy's LangChain translator (``None`` to drop it).""" - name = event.get("event") - run_id = event.get("run_id", "") - ev_name = event.get("name") - data = event.get("data") or {} - - if name in ("on_chat_model_stream", "on_llm_stream"): - text = _text_of(data.get("chunk")) - if not text: - return None - return {"event": name, "run_id": run_id, "name": ev_name, - "data": {"chunk": {"content": text}}} - - if name in ("on_chat_model_end", "on_llm_end"): - return {"event": name, "run_id": run_id, "name": ev_name, - "data": {"output": {"content": _text_of(data.get("output"))}}} - - if name == "on_tool_start": - return {"event": name, "run_id": run_id, "name": ev_name, - "data": {"input": _jsonable(data.get("input"))}} - - if name == "on_tool_end": - return {"event": name, "run_id": run_id, "name": ev_name, - "data": {"output": _text_of(data.get("output"))}} - - return None diff --git a/demo/agent-server/src/agent_server/agents/langchain_events.py b/demo/agent-server/src/agent_server/agents/langchain_events.py new file mode 100644 index 0000000..b6945e0 --- /dev/null +++ b/demo/agent-server/src/agent_server/agents/langchain_events.py @@ -0,0 +1,81 @@ +"""Shared helpers for the deepagents/LangChain agents (itsm, hr). + +``astream_events`` yields live LangChain objects (AIMessageChunk, ToolMessage, …) +the agent-server's ``json.dumps`` framing can't serialize, so ``to_native_event`` +projects each down to the plain wire shape the proxy's LangChain translator reads; +ignored events (on_chain_*, on_retriever_*, …) are dropped. +""" + +from __future__ import annotations + +from typing import Any + +from .. import protocol + + +def messages_input(input: Any) -> dict[str, Any]: + """Coerce the contract input into the ``{"messages": [...]}`` LangGraph wants.""" + messages = (input or {}).get("messages") if isinstance(input, dict) else None + if isinstance(messages, list) and messages: + return {"messages": messages} + return {"messages": [{"role": "user", "content": protocol.last_user_text(input)}]} + + +def _text_of(obj: Any) -> str: + """Pull printable text from a LangChain message/chunk (or dict/str).""" + content = getattr(obj, "content", None) + if content is None and isinstance(obj, dict): + content = obj.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [ + p["text"] + for p in content + if isinstance(p, dict) and isinstance(p.get("text"), str) + ] + return "".join(parts) + return obj if isinstance(obj, str) else "" + + +def _jsonable(value: Any) -> Any: + """Best-effort JSON-safe coercion for tool-call arguments.""" + import json + + try: + json.dumps(value) + return value + except TypeError: + if isinstance(value, dict): + return {k: _jsonable(v) for k, v in value.items()} + return str(value) + + +def to_native_event(event: dict[str, Any]) -> dict | None: + """Project one LangChain ``astream_events`` item into a JSON-safe native + event for the proxy's LangChain translator (``None`` to drop it).""" + name = event.get("event") + run_id = event.get("run_id", "") + ev_name = event.get("name") + data = event.get("data") or {} + + if name in ("on_chat_model_stream", "on_llm_stream"): + text = _text_of(data.get("chunk")) + if not text: + return None + return {"event": name, "run_id": run_id, "name": ev_name, + "data": {"chunk": {"content": text}}} + + if name in ("on_chat_model_end", "on_llm_end"): + return {"event": name, "run_id": run_id, "name": ev_name, + "data": {"output": {"content": _text_of(data.get("output"))}}} + + if name == "on_tool_start": + return {"event": name, "run_id": run_id, "name": ev_name, + "data": {"input": _jsonable(data.get("input"))}} + + if name == "on_tool_end": + return {"event": name, "run_id": run_id, "name": ev_name, + "data": {"output": _text_of(data.get("output"))}} + + return None diff --git a/demo/agent-server/src/agent_server/agents/select.py b/demo/agent-server/src/agent_server/agents/select.py index 425dfbe..4f9d254 100644 --- a/demo/agent-server/src/agent_server/agents/select.py +++ b/demo/agent-server/src/agent_server/agents/select.py @@ -41,6 +41,11 @@ def select_agent(agent_id: str, context: dict[str, Any]) -> Agent: return ItsmAgent() + if agent_id == "hr": + from .hr import HrAgent + + return HrAgent() + if framework == "langchain": return LangChainDemoAgent() if framework == "openai-agents": diff --git a/demo/agent-server/src/agent_server/roster.py b/demo/agent-server/src/agent_server/roster.py index c7f9dbb..e4fc67e 100644 --- a/demo/agent-server/src/agent_server/roster.py +++ b/demo/agent-server/src/agent_server/roster.py @@ -84,6 +84,17 @@ "ui_url": "/agents/itsm/ui", "framework": "langchain", }, + # HR — a real LangChain agent (create_react_agent); HexGate wrapping is opt-in + # (enabled by setting HEXGATE_KEY). Showcases role-based field-level scoping + # over an employee record (default < manager < gestionnaire_rh). + { + "id": "hr", + "name": "HR", + "role": "People assistant", + "main_color": "#0d9488", + "ui_url": "/agents/hr/ui", + "framework": "langchain", + }, ] _BY_ID = {a["id"]: a for a in AGENTS} diff --git a/demo/agent-server/src/agent_server/ui/hr.yaml b/demo/agent-server/src/agent_server/ui/hr.yaml new file mode 100644 index 0000000..2621d3d --- /dev/null +++ b/demo/agent-server/src/agent_server/ui/hr.yaml @@ -0,0 +1,30 @@ +# HR — people assistant (LangChain). Served by GET /agents/hr/ui. +# Chat + a tool-calls panel so the HR tools (directory lookup, field reads, salary +# updates, payroll export, offboarding…) are visible as they fire — and you can +# see which the policy allows vs denies per role. The active agent's main_color is +# the only accent in the product. +page: + layout_type: grid + main_color: "#0d9488" + +widgets: + - name: transcript + type: ai-response + position: { horizontal: left, vertical: high } + size: { width: 8, height: 520 } + empty_text: "Ask the HR assistant — e.g. \"Donne-moi le titre et le service de Sophie Martin.\"" + thinking_indicator: dots + + - name: tool-calls + type: tool-calls + position: { horizontal: right, vertical: high } + size: { width: 4, height: 520 } + title: HR steps + empty_text: "Tool calls (directory, record fields, salary, payroll, offboarding…) appear here." + + - name: chat-input + type: ai-chat-input + position: { horizontal: left, vertical: low } + size: { width: 12, height: auto } + placeholder: "Message the HR assistant…" + rows: 2 From 5b5cf7ddb6712204a6d2508ccd6cd5b3466cdc2f Mon Sep 17 00:00:00 2001 From: User Date: Fri, 19 Jun 2026 15:32:27 +0200 Subject: [PATCH 2/4] refactor: update makefile, doc and users file to include hr agent --- Makefile | 7 +++++-- QUICKSTART.md | 4 +++- demo-users.yaml | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 11a6109..1d2240d 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,7 @@ install-hexgate: ## Ensure the agent-server venv is Python 3.13 with the hexgate @$(AGENT_PY) -c 'import deepagents' 2>/dev/null || \ uv pip install --python demo/agent-server/.venv -e 'demo/agent-server[itsm]' -register: install-hexgate ## Register the healthcare + devops + itsm agents on the HexGate platform (reads HEXGATE_KEY from demo/agent-server/.env). +register: install-hexgate ## Register the healthcare + devops + itsm + hr agents on the HexGate platform (reads HEXGATE_KEY from demo/agent-server/.env). @if [ -f demo/agent-server/.env ]; then set -a; . demo/agent-server/.env; set +a; fi; \ if [ -z "$$HEXGATE_KEY" ]; then \ echo "HEXGATE_KEY not set — add it to demo/agent-server/.env or export it."; exit 1; fi; \ @@ -65,7 +65,10 @@ register: install-hexgate ## Register the healthcare + devops + itsm agents on t PYTHONPATH=$(AGENT_PATH) $(HEXGATE) register --agent agent_server.agents.devops_agent:agent && \ echo "→ registering itsm_agent" && \ PYTHONPATH=$(AGENT_PATH) $(HEXGATE) register --agent agent_server.agents.itsm_agent:agent \ - --tools agent_server.agents.itsm_agent:TOOLS --model gpt-4o-mini + --tools agent_server.agents.itsm_agent:TOOLS --model gpt-4o-mini && \ + echo "→ registering hr_agent" && \ + PYTHONPATH=$(AGENT_PATH) $(HEXGATE) register --agent agent_server.agents.hr_agent:agent \ + --tools agent_server.agents.hr_agent:TOOLS --model gpt-4o-mini # -- test ------------------------------------------------------------------- test: ## Run the proxy test suite. diff --git a/QUICKSTART.md b/QUICKSTART.md index 18cef4e..cb63a3c 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -79,8 +79,10 @@ Open . `bianca@clinic.org` (billing_staff) — healthcare roles - `alice@hexamind.ai` (requester), `carla@hexamind.ai` (implementer), `emma@hexamind.ai` (change_manager), `gabriel@hexamind.ai` (cab_manager) — itsm roles + - `hugo@hexamind.ai` (default), `manon@hexamind.ai` (manager), + `chloe@hexamind.ai` (gestionnaire_rh) — hr roles - The `role` only matters for HexGate-gated agents (healthcare / devops / itsm + The `role` only matters for HexGate-gated agents (healthcare / devops / itsm / hr with `HEXGATE_KEY` set), where it scopes the per-tool policy. The accounts come from [`demo-users.yaml`](demo-users.yaml), upserted on startup when `PLATFORM_DEMO_USERS_FILE` is set (the `make dev` launcher sets diff --git a/demo-users.yaml b/demo-users.yaml index da1ef0a..ddcabd3 100644 --- a/demo-users.yaml +++ b/demo-users.yaml @@ -71,3 +71,19 @@ users: password: hexademo name: Gabriel Laurent # read all + schedule decision only (Authorize→Schedule) role: cab_manager + + # ── HR agent ── roles: default < manager < gestionnaire_rh (hr_policy.yaml). + # One user per role; the role widens the readable employee fields and unlocks + # salary/medical/export/offboarding. + - email: hugo@hexamind.ai + password: hexademo + name: Hugo Bernard # annuaire + annuaire fields only + role: default + - email: manon@hexamind.ai + password: hexademo + name: Manon Lefevre # + leave_balance / performance_rating (own team) + role: manager + - email: chloe@hexamind.ai + password: hexademo + name: Chloe Garnier # paie, médical, export (≤1000), offboarding + role: gestionnaire_rh From 22b50a288df963fa88d41ae70606d966f0fb9046 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 19 Jun 2026 16:01:18 +0200 Subject: [PATCH 3/4] refactor: several minor fixes --- demo-users.yaml | 2 +- demo/agent-server/pyproject.toml | 2 +- .../src/agent_server/agents/hr_agent.py | 28 +++++++++++++------ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/demo-users.yaml b/demo-users.yaml index ddcabd3..a04a250 100644 --- a/demo-users.yaml +++ b/demo-users.yaml @@ -81,7 +81,7 @@ users: role: default - email: manon@hexamind.ai password: hexademo - name: Manon Lefevre # + leave_balance / performance_rating (own team) + name: Manon Lefevre # + leave_balance / performance_rating (team-scoped in a real impl; stubs gate by field only) role: manager - email: chloe@hexamind.ai password: hexademo diff --git a/demo/agent-server/pyproject.toml b/demo/agent-server/pyproject.toml index 8311177..9e1ad36 100644 --- a/demo/agent-server/pyproject.toml +++ b/demo/agent-server/pyproject.toml @@ -22,7 +22,7 @@ llm = [ itsm = [ "langchain-openai>=0.2", # model backend (ChatOpenAI) "langchain-core>=0.3", # tools (@tool) - "langgraph>=0.2", # HR agent (create_react_agent) + "langgraph>=0.2.46", # HR agent (create_react_agent; `prompt=` kwarg lands in 0.2.46) "deepagents>=0.0.5", # ITSM agent (create_deep_agent over LangGraph) ] dev = [ diff --git a/demo/agent-server/src/agent_server/agents/hr_agent.py b/demo/agent-server/src/agent_server/agents/hr_agent.py index aa33dd3..0c85a79 100644 --- a/demo/agent-server/src/agent_server/agents/hr_agent.py +++ b/demo/agent-server/src/agent_server/agents/hr_agent.py @@ -12,8 +12,11 @@ arg, and each role widens the allowed ``args.field`` allowlist; - the ultra-sensitive medical read is its own tool (``get_medical_leave``) so it gates separately; salary writes, the aggregated payroll view, and offboarding - are gestionnaire_rh-only; ``export_payroll`` is volume-capped (``args.count``) - so a prompt-injected agent can't siphon the whole payroll. + are gestionnaire_rh-only; ``export_payroll`` declares a payslip ``count`` the + policy caps per role, so an over-limit export is denied at the gate. The cap + bounds the volume the agent *declares* — not a model that under-reports it; a + real export would derive ``count`` server-side from the actual selection + rather than trust the model's estimate. The tools are stubs (no datastore) — the demo is about the policy, not the data. ``stream`` / ``stream_as`` yield LangChain ``astream_events`` items the proxy's @@ -48,9 +51,13 @@ def _actor() -> str: # --------------------------------------------------------------------------- -# Tools — stubs. The constraint engine only sees the call's `args`; any check it -# can't express (row-level "son équipe" scope, name → id resolution) belongs in -# the body, keyed off the trusted User identity rather than a model-supplied arg. +# Tools — stubs (no datastore), so the only gating they demonstrate is the +# policy's arg-level check on each call's `args` (e.g. `args.field`, `args.count`). +# Checks the constraint engine can't express — row-level "son équipe" scope, +# name → id resolution — would live in the tool body keyed off the trusted User +# identity (as the ITSM agent does via `_actor`); these stubs deliberately don't +# implement them, so a caller is bounded by field-by-role gating only, NOT by +# which employees are theirs to see. # --------------------------------------------------------------------------- @@ -129,10 +136,15 @@ def view_compensation(team: str) -> str: def export_payroll(period: str, count: int) -> str: """Export `count` payslips for the given `period` (e.g. '2026-01'). - Pass `count` = the number of payslips the export would produce. The - policy caps this per role so a runaway request can't dump the whole - company's payroll. + Pass `count` = the number of payslips the export would produce. The policy + enforces a per-role ceiling on `count`, so an export whose declared volume + exceeds the caller's limit is denied at the gate. This bounds the volume the + agent *declares*, not a model that under-reports `count` — a real export + would derive `count` server-side from the actual selection rather than trust + the model's estimate. """ + if count <= 0: + return "(stub) export refusé — `count` doit être un entier positif." return f"(stub) export de {count} bulletins de paie pour {period} → PAY-EXP-3391" From c1ce42c3da99581f6a29f53ca6a6e78580c613af Mon Sep 17 00:00:00 2001 From: User Date: Fri, 19 Jun 2026 16:14:51 +0200 Subject: [PATCH 4/4] refactor: fallback to default role for devops agent --- QUICKSTART.md | 2 +- demo-users.yaml | 4 ++-- demo/agent-server/src/agent_server/agents/devops.py | 9 ++++++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/QUICKSTART.md b/QUICKSTART.md index cb63a3c..44a22cc 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -73,7 +73,7 @@ Open . 1. Loading the app redirects you to **/login** (the route guard fires). 2. Log in as one of the demo accounts (all share the same password, `hexademo`): - `guest@example.com` (no role) — exercises the fail-closed `default` (deny) - - `vince@hexamind.ai` (viewer), `olivia@hexamind.ai` (operator), + - `vince@hexamind.ai` (default), `olivia@hexamind.ai` (operator), `aaron@hexamind.ai` (admin) — devops roles - `nadia@clinic.org` (nurse), `priya@clinic.org` (physician), `bianca@clinic.org` (billing_staff) — healthcare roles diff --git a/demo-users.yaml b/demo-users.yaml index f5da305..82c8d3f 100644 --- a/demo-users.yaml +++ b/demo-users.yaml @@ -36,7 +36,7 @@ users: # ── Hexamind org (DevOps + ITSM) ── one merged group; everyone can reach BOTH # agents. Each keeps a single role, meaningful to one agent; talking to the # other falls through to that policy's fail-closed `default` (deny). - # DevOps roles: viewer < operator < admin (devops_policy.yaml) + # DevOps roles: default < operator < admin (devops_policy.yaml) # ITSM roles: requester, implementer, change_manager, cab_manager # (itsm_policy.yaml — ownership/scope key off `name`, which # must match the seed in itsm_db.py: Alice Martin owns @@ -44,7 +44,7 @@ users: - email: vince@hexamind.ai password: hexademo name: Vince Reilly # devops: read service logs only - role: viewer + role: default agents: [devops, itsm, hr] - email: olivia@hexamind.ai password: hexademo diff --git a/demo/agent-server/src/agent_server/agents/devops.py b/demo/agent-server/src/agent_server/agents/devops.py index e737f35..35e6003 100644 --- a/demo/agent-server/src/agent_server/agents/devops.py +++ b/demo/agent-server/src/agent_server/agents/devops.py @@ -18,6 +18,11 @@ logger = logging.getLogger("agent_server.devops") +# The elevated DevOps roles (devops_policy.yaml). Anything else — no role, or a +# role from another agent (nurse, requester…) — normalizes to the `default` +# baseline (read logs) rather than passing an unrecognized string to the policy. +_DEVOPS_ROLES = {"operator", "admin"} + class DevopsAgent: framework = "google-adk" @@ -45,7 +50,9 @@ async def run( # user block. caller = protocol.caller(context) user_id = caller.get("id") or "hexui-demo" - role = caller.get("role") or os.getenv("HEXGATE_ROLE", "operator") + role = caller.get("role") or os.getenv("HEXGATE_ROLE", "default") + if role not in _DEVOPS_ROLES: + role = "default" events = devops_agent.stream_as(text, user_id=user_id, role=role) else: events = devops_agent.stream(text)