From 31cf081ae1d56d1d9309650c768506e4375d3a2a Mon Sep 17 00:00:00 2001 From: User Date: Wed, 15 Jul 2026 13:55:10 +0200 Subject: [PATCH] refactor(*): set whole repo up to date with latest hexgate version --- Makefile | 11 +++++----- QUICKSTART.md | 2 +- README.md | 6 +++--- demo/agent-server/.env.sample | 2 +- demo/agent-server/pyproject.toml | 4 ++-- .../agent-server/src/agent_server/__main__.py | 2 +- .../clinic_org/healthcare/healthcare.py | 21 ++++++++++++++++++- .../src/agent_server/agents/shared/hr/hr.py | 2 +- .../agents/tech_org/devops/devops.py | 21 ++++++++++++++++++- .../agents/tech_org/devops/devops_agent.py | 2 +- .../agent_server/agents/tech_org/itsm/itsm.py | 2 +- .../agent-server/src/agent_server/protocol.py | 10 ++++++--- demo/agent-server/src/agent_server/roster.py | 10 ++++----- demo/hexgate-agent/README.md | 4 ++-- demo/hexgate-agent/src/hexgate_agent/agent.py | 2 +- demo/scripts/run-backends.sh | 2 +- demo/scripts/setup.sh | 4 ++-- front-app/src/runtime/runtimeBridge.ts | 14 +++++++++++++ .../translators/google_adk.py | 8 +++++++ .../platform_backend/translators/native.py | 4 +++- .../translators/openai_agents.py | 4 +++- 21 files changed, 103 insertions(+), 34 deletions(-) diff --git a/Makefile b/Makefile index 31289fb..1597ebe 100644 --- a/Makefile +++ b/Makefile @@ -50,15 +50,16 @@ install-hexgate: ## Ensure the agent-server venv is Python 3.13 with the hexgate @if ! $(AGENT_PY) -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 13) else 1)' 2>/dev/null; then \ echo "→ (re)creating agent-server venv on Python 3.13 (hexgate needs >=3.13)"; \ uv venv --python 3.13 demo/agent-server/.venv; fi - @$(AGENT_PY) -c 'import hexgate' 2>/dev/null || \ - uv pip install --python demo/agent-server/.venv -e 'demo/agent-server[dev,llm,hexgate]' + @# Always sync via uv so a bumped pin in pyproject.toml (e.g. hexgate>=…) is + @# actually applied. A bare `import hexgate` guard would keep a stale version. + @uv pip install --python demo/agent-server/.venv -e 'demo/agent-server[dev,llm,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 + hr 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_API_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; \ + if [ -z "$$HEXGATE_API_KEY" ]; then \ + echo "HEXGATE_API_KEY not set — add it to demo/agent-server/.env or export it."; exit 1; fi; \ echo "→ registering healthcare_agent"; \ PYTHONPATH=$(AGENT_PATH) $(HEXGATE) register --agent agent_server.agents.clinic_org.healthcare.healthcare_agent:agent && \ echo "→ registering devops_agent" && \ diff --git a/QUICKSTART.md b/QUICKSTART.md index 20faa3d..6d53594 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -83,7 +83,7 @@ Open . `chloe@hexamind.ai` (gestionnaire_rh) — hr roles 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 + with `HEXGATE_API_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 it for you). Or sign up a fresh account at **/signup**. diff --git a/README.md b/README.md index a501d9d..59acc9c 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ one of the demo accounts (all share the password `hexademo`): - `nadia@clinic.org` (nurse), `priya@clinic.org` (physician), `bianca@clinic.org` (billing_staff) — healthcare roles The `role` only changes behavior when an agent is HexGate-gated (healthcare / -devops with `HEXGATE_KEY` set); it scopes that agent's per-tool policy. +devops with `HEXGATE_API_KEY` set); it scopes that agent's per-tool policy. Or sign up a fresh account at `/signup`. The demo accounts come from [`demo-users.yaml`](demo-users.yaml), upserted on startup when @@ -122,8 +122,8 @@ The bundled agents demonstrate the contract end to end: | **Orbit** | `google-adk` (Gemini) | a real LLM **plus** the widget actions + `data_source` workspace | | **Atlas** | `langchain` | the LangChain translator (canned native events) | | **Forge** | `openai-agents` | the OpenAI Agents translator (canned native events) | -| **Healthcare** | `openai-agents` (OpenAI) | a real clinical-assistant agent; HexGate-gated when `HEXGATE_KEY` is set, scoping per-tool policy to the caller's `context.user` role | -| **DevOps** | `google-adk` (OpenAI via LiteLLM) | a real infra-assistant agent; HexGate-gated when `HEXGATE_KEY` is set, scoping per-tool policy to the caller's `context.user` role | +| **Healthcare** | `openai-agents` (OpenAI) | a real clinical-assistant agent; HexGate-gated when `HEXGATE_API_KEY` is set, scoping per-tool policy to the caller's `context.user` role | +| **DevOps** | `google-adk` (OpenAI via LiteLLM) | a real infra-assistant agent; HexGate-gated when `HEXGATE_API_KEY` is set, scoping per-tool policy to the caller's `context.user` role | | **ITSM** | `langchain` (deepagents) | a change-request assistant with a live lifecycle dashboard (refresh button → funnel metrics + change table updates as the agent's tools run) | | **HR** | `langchain` (deepagents) | an internal HR assistant; demonstrates stateful per-user data (`hr_state.py`) and role-gated tools when HexGate is wired | | **Hexgate Guard** | `hexgate` | a hexgate-wrapped agent that opens `User(user_id, role)` per run and emits audit decisions to the hexgate cloud (separate backend at [`demo/hexgate-agent/`](demo/hexgate-agent/)) | diff --git a/demo/agent-server/.env.sample b/demo/agent-server/.env.sample index 98abbbb..95dd0c0 100644 --- a/demo/agent-server/.env.sample +++ b/demo/agent-server/.env.sample @@ -10,7 +10,7 @@ GOOGLE_API_KEY= # HexGate platform key. Set this to route the healthcare and devops agents # through the HexGate-gated path (every tool call is policy-checked); leave # empty for the plain SDK path. -HEXGATE_KEY= +HEXGATE_API_KEY= # Role the HexGate path runs as. Normally the role comes from the signed-in # HexUI user (context.user.role, set in the Settings UI). HEXGATE_ROLE is only diff --git a/demo/agent-server/pyproject.toml b/demo/agent-server/pyproject.toml index 513796e..eae85b1 100644 --- a/demo/agent-server/pyproject.toml +++ b/demo/agent-server/pyproject.toml @@ -31,10 +31,10 @@ dev = [ "httpx>=0.27", "ruff>=0.6", ] -# Opt-in HexGate wrapping (enabled by setting HEXGATE_KEY). Not in the default setup.sh; +# Opt-in HexGate wrapping (enabled by setting HEXGATE_API_KEY). Not in the default setup.sh; # needs Python >=3.13 (the hexgate floor). Without it, the plain healthcare path # still works. -hexgate = ["hexgate>=0.2"] +hexgate = ["hexgate>=0.2.9"] [build-system] requires = ["hatchling"] diff --git a/demo/agent-server/src/agent_server/__main__.py b/demo/agent-server/src/agent_server/__main__.py index 5bcb1b4..232d8cb 100644 --- a/demo/agent-server/src/agent_server/__main__.py +++ b/demo/agent-server/src/agent_server/__main__.py @@ -7,7 +7,7 @@ import uvicorn -# Load secrets (OPENAI_API_KEY, GOOGLE_API_KEY, HEXGATE_KEY) from the +# Load secrets (OPENAI_API_KEY, GOOGLE_API_KEY, HEXGATE_API_KEY) from the # agent-server .env. Real env vars take precedence. try: from dotenv import load_dotenv diff --git a/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare.py b/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare.py index e797473..160588d 100644 --- a/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare.py +++ b/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare.py @@ -39,7 +39,12 @@ async def run( set_default_openai_key(api_key) # HexGate-gated path whenever HexGate is configured; plain SDK otherwise. - if os.getenv("HEXGATE_KEY"): + # `banned_error` is the ban exception type, resolved only on the gated + # path so the plain path never imports the optional hexgate dependency. + banned_error: type[Exception] | None = None + if os.getenv("HEXGATE_API_KEY"): + from hexgate.security import AgentBannedError as banned_error + # Scope policy decisions to the signed-in HexKit user. `id` / `role` # ride in `context.user` (CONTRACT.md §5); fall back to the static # demo identity and HEXGATE_ROLE for standalone runs that send no @@ -60,5 +65,19 @@ async def run( if native_event is not None: yield native_event except Exception as exception: # noqa: BLE001 — degrade to a visible error event + # A kill-switch ban refuses the run before the LLM. Surface it as a + # structured error the frontend can recognize (and localize) rather + # than a generic failure. + if banned_error is not None and isinstance(exception, banned_error): + yield protocol.error( + exception.user_message, + details={ + "code": exception.code, + "ban_type": exception.ban_type, + "target": exception.target, + "reason": exception.reason, + }, + ) + return logger.exception("healthcare run failed") yield protocol.error(f"agent failed: {exception}") diff --git a/demo/agent-server/src/agent_server/agents/shared/hr/hr.py b/demo/agent-server/src/agent_server/agents/shared/hr/hr.py index f00e380..af861b8 100644 --- a/demo/agent-server/src/agent_server/agents/shared/hr/hr.py +++ b/demo/agent-server/src/agent_server/agents/shared/hr/hr.py @@ -42,7 +42,7 @@ async def run( from . import hr_agent # HexGate-gated whenever configured; plain graph otherwise. - if os.getenv("HEXGATE_KEY"): + if os.getenv("HEXGATE_API_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) diff --git a/demo/agent-server/src/agent_server/agents/tech_org/devops/devops.py b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops.py index c1a5c2c..6f34042 100644 --- a/demo/agent-server/src/agent_server/agents/tech_org/devops/devops.py +++ b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops.py @@ -45,7 +45,12 @@ async def run( text = protocol.last_user_text(input) # HexGate-gated path whenever HexGate is configured; plain ADK otherwise. - if os.getenv("HEXGATE_KEY"): + # `banned_error` is the ban exception type, resolved only on the gated + # path so the plain path never imports the optional hexgate dependency. + banned_error: type[Exception] | None = None + if os.getenv("HEXGATE_API_KEY"): + from hexgate.security import AgentBannedError as banned_error + # Scope policy decisions to the signed-in HexKit user. `id` / `role` # ride in `context.user` (CONTRACT.md §5); fall back to the static # demo identity and HEXGATE_ROLE for standalone runs that send no @@ -65,5 +70,19 @@ async def run( if native_event is not None: yield native_event except Exception as exception: # noqa: BLE001 — degrade to a visible error event + # A kill-switch ban refuses the run before the LLM. Surface it as a + # structured error the frontend can recognize (and localize) rather + # than a generic failure. + if banned_error is not None and isinstance(exception, banned_error): + yield protocol.error( + exception.user_message, + details={ + "code": exception.code, + "ban_type": exception.ban_type, + "target": exception.target, + "reason": exception.reason, + }, + ) + return logger.exception("devops run failed") yield protocol.error(f"agent failed: {exception}") diff --git a/demo/agent-server/src/agent_server/agents/tech_org/devops/devops_agent.py b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops_agent.py index 0bb2b0a..ebf7c69 100644 --- a/demo/agent-server/src/agent_server/agents/tech_org/devops/devops_agent.py +++ b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops_agent.py @@ -122,7 +122,7 @@ async def stream_as(text: str, *, user_id: str, role: str) -> AsyncIterator[Any] """Same as :func:`stream`, but through HexGate as ``user_id`` / ``role`` — every tool call is policy-gated against the calling user. The caller's ``role`` (viewer < operator < admin) is what flips each decision. - ``HexgateRunner`` reads ``HEXGATE_KEY`` from the environment. + ``HexgateRunner`` reads ``HEXGATE_API_KEY`` from the environment. ``user_id`` and ``role`` come from the HexKit caller (``context.user``); the wrapper in ``devops.py`` resolves them. diff --git a/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm.py b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm.py index 301f990..5e528e3 100644 --- a/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm.py +++ b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm.py @@ -37,7 +37,7 @@ async def run( from . import itsm_agent # HexGate-gated whenever configured; plain graph otherwise. - if os.getenv("HEXGATE_KEY"): + if os.getenv("HEXGATE_API_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) diff --git a/demo/agent-server/src/agent_server/protocol.py b/demo/agent-server/src/agent_server/protocol.py index 6bf483c..3733898 100644 --- a/demo/agent-server/src/agent_server/protocol.py +++ b/demo/agent-server/src/agent_server/protocol.py @@ -14,7 +14,8 @@ "args": {...}, "widget": "tool-calls"} (args, widget optional) {"type": "tool_result", "id": "t1", "output": {...}} a tool call ends {"type": "tool_result", "id": "t1", "error": "..."} (output OR error) - {"type": "error", "message": "..."} the run failed + {"type": "error", "message": "...", "details": {...}} the run failed + (details optional — e.g. a kill-switch ban) {"type": "done"} optional; EOF also ends The builder functions below are conveniences; emitting the plain dicts directly @@ -56,8 +57,11 @@ def tool_result(id: str, output: Any = None, error: str | None = None) -> dict: return ev -def error(message: str) -> dict: - return {"type": "error", "message": message} +def error(message: str, details: dict[str, Any] | None = None) -> dict: + ev: dict[str, Any] = {"type": "error", "message": message} + if details: + ev["details"] = details + return ev def done() -> dict: diff --git a/demo/agent-server/src/agent_server/roster.py b/demo/agent-server/src/agent_server/roster.py index f8c4b06..d01135f 100644 --- a/demo/agent-server/src/agent_server/roster.py +++ b/demo/agent-server/src/agent_server/roster.py @@ -23,10 +23,10 @@ # the proxy rewrites/serves it under its own /agents/{id}/ui. ``framework`` tells # the proxy which translator to apply to this agent's native event stream # (`native` is the escape hatch). Each entry is a real agent; HexGate wrapping is -# opt-in (enabled by setting HEXGATE_KEY). +# opt-in (enabled by setting HEXGATE_API_KEY). AGENTS: list[dict[str, str]] = [ # Healthcare — a real OpenAI Agents SDK agent; HexGate wrapping is opt-in - # (enabled by setting HEXGATE_KEY). + # (enabled by setting HEXGATE_API_KEY). { "id": "healthcare", "name": "Healthcare", @@ -36,7 +36,7 @@ "framework": "openai-agents", }, # DevOps — a real Google ADK agent (LiteLLM over an OpenAI model); HexGate - # wrapping is opt-in (enabled by setting HEXGATE_KEY). + # wrapping is opt-in (enabled by setting HEXGATE_API_KEY). { "id": "devops", "name": "DevOps", @@ -46,7 +46,7 @@ "framework": "google-adk", }, # ITSM — a real deepagents/LangChain agent; HexGate wrapping is opt-in - # (enabled by setting HEXGATE_KEY). The real-LLM showcase for the LangChain + # (enabled by setting HEXGATE_API_KEY). The real-LLM showcase for the LangChain # translator (RBAC + state-machine guard on a Change-Request workflow). { "id": "itsm", @@ -57,7 +57,7 @@ "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 + # (enabled by setting HEXGATE_API_KEY). Showcases role-based field-level scoping # over an employee record (default < manager < gestionnaire_rh). { "id": "hr", diff --git a/demo/hexgate-agent/README.md b/demo/hexgate-agent/README.md index 46e3bc9..84b650b 100644 --- a/demo/hexgate-agent/README.md +++ b/demo/hexgate-agent/README.md @@ -35,7 +35,7 @@ hexgate's own envelope events and re-drives the emitter from the content events - **Python ≥ 3.13** — hexgate requires it (the other demo backends only need 3.11). The venv below must use a 3.13+ interpreter. -- **`HEXGATE_KEY`** (optional, for cloud audit) — the dev/admin key that +- **`HEXGATE_API_KEY`** (optional, for cloud audit) — the dev/admin key that authenticates this backend to your hexgate cloud project. Per-request biscuit attenuation scopes each call down to the HexKit user, so one key serves all HexKit users. @@ -49,7 +49,7 @@ uv venv --python 3.13 demo/hexgate-agent/.venv uv pip install --python demo/hexgate-agent/.venv -e 'demo/hexgate-agent[dev]' # Optional: stream policy decisions to the hexgate cloud dashboard. -export HEXGATE_KEY=... +export HEXGATE_API_KEY=... demo/hexgate-agent/.venv/bin/python -m hexgate_agent # serves on :8080 ``` diff --git a/demo/hexgate-agent/src/hexgate_agent/agent.py b/demo/hexgate-agent/src/hexgate_agent/agent.py index 304fe6d..1afb1dc 100644 --- a/demo/hexgate-agent/src/hexgate_agent/agent.py +++ b/demo/hexgate-agent/src/hexgate_agent/agent.py @@ -19,7 +19,7 @@ - per-request biscuit attenuation by ``HexgateClient``; - audit events POSTed to the hexgate cloud, tagged with the HexKit user. - To see those audit events on the cloud dashboard, set ``HEXGATE_KEY`` in + To see those audit events on the cloud dashboard, set ``HEXGATE_API_KEY`` in this process's env (it's the dev/admin key — biscuits attenuate per request to scope back down to the HexKit user). diff --git a/demo/scripts/run-backends.sh b/demo/scripts/run-backends.sh index c0b1b85..bc101d7 100644 --- a/demo/scripts/run-backends.sh +++ b/demo/scripts/run-backends.sh @@ -26,7 +26,7 @@ trap cleanup EXIT INT TERM # LLMAgent when OPENAI_API_KEY is set in the env (else it falls back to echo). export AGENT_ENABLE_LLM="${AGENT_ENABLE_LLM:-0}" -# Export agent-server secrets (OPENAI_API_KEY, GOOGLE_API_KEY, HEXGATE_KEY) from +# Export agent-server secrets (OPENAI_API_KEY, GOOGLE_API_KEY, HEXGATE_API_KEY) from # its .env. The agent backend reads its provider keys from this env — HexKit # never sends them. if [ -f demo/agent-server/.env ]; then diff --git a/demo/scripts/setup.sh b/demo/scripts/setup.sh index 37c81dc..2e65dcf 100644 --- a/demo/scripts/setup.sh +++ b/demo/scripts/setup.sh @@ -49,7 +49,7 @@ echo echo "done. start the stack with:" echo " AGENT_ENABLE_LLM=1 bash demo/scripts/run-backends.sh" echo -echo "Optional — HexGate-wrapped healthcare + devops agents (set HEXGATE_KEY to enable):" +echo "Optional — HexGate-wrapped healthcare + devops agents (set HEXGATE_API_KEY to enable):" echo " hexgate (from PyPI) needs a Python >=3.13 venv. Let the Makefile handle it:" echo " make install-hexgate # (re)create the agent-server venv on 3.13 with the hexgate extra" -echo " make register # register the agents (reads HEXGATE_KEY from demo/agent-server/.env)" +echo " make register # register the agents (reads HEXGATE_API_KEY from demo/agent-server/.env)" diff --git a/front-app/src/runtime/runtimeBridge.ts b/front-app/src/runtime/runtimeBridge.ts index f0efcce..d2be9b8 100644 --- a/front-app/src/runtime/runtimeBridge.ts +++ b/front-app/src/runtime/runtimeBridge.ts @@ -333,6 +333,20 @@ export class RuntimeBridge implements AgentBridge { role: "system", content: "Run cancelled.", }); + } else if ( + event.details && + (event.details.code === "agent_banned" || + event.details.code === "user_banned") + ) { + // A kill-switch ban refused the run before the LLM. Show the + // administrator's message as a system notice rather than an error; + // append the operator-supplied reason when one was provided. + const reason = event.details.reason; + const content = + typeof reason === "string" && reason.trim() + ? `${event.message} (${reason})` + : event.message; + this.emit({ kind: "message", role: "system", content }); } else { this.emit({ kind: "error", message: event.message }); } diff --git a/proxy-server/src/platform_backend/translators/google_adk.py b/proxy-server/src/platform_backend/translators/google_adk.py index 81ea33f..8535ad2 100644 --- a/proxy-server/src/platform_backend/translators/google_adk.py +++ b/proxy-server/src/platform_backend/translators/google_adk.py @@ -40,6 +40,14 @@ def __init__(self) -> None: def handle(self, emitter: RunEmitter, event: dict[str, Any]) -> list[StreamEvent]: out: list[StreamEvent] = [] + # A native error frame (e.g. a kill-switch ban) is not an ADK Event — it + # carries no `content.parts`, so surface it directly rather than letting + # the parts loop below silently drop it. + if event.get("type") == "error": + return emitter.error( + event.get("message", "") or "", details=event.get("details") or {} + ) + # Author switch (multi-agent) closes the previous author's open block. author = event.get("author") if author and author != self._last_author: diff --git a/proxy-server/src/platform_backend/translators/native.py b/proxy-server/src/platform_backend/translators/native.py index 4a9afab..cb4c443 100644 --- a/proxy-server/src/platform_backend/translators/native.py +++ b/proxy-server/src/platform_backend/translators/native.py @@ -68,7 +68,9 @@ def handle(self, emitter: RunEmitter, event: dict[str, Any]) -> list[StreamEvent ) if etype == "error": - return emitter.error(event.get("message", "") or "") + return emitter.error( + event.get("message", "") or "", details=event.get("details") or {} + ) # "done" and unknown types: nothing (run_end is synthesized by the route). return [] diff --git a/proxy-server/src/platform_backend/translators/openai_agents.py b/proxy-server/src/platform_backend/translators/openai_agents.py index ccd5a5a..85bed23 100644 --- a/proxy-server/src/platform_backend/translators/openai_agents.py +++ b/proxy-server/src/platform_backend/translators/openai_agents.py @@ -43,7 +43,9 @@ def handle(self, emitter: RunEmitter, event: dict[str, Any]) -> list[StreamEvent etype = event.get("type") if etype == "error": - return emitter.error(event.get("message", "") or "") + return emitter.error( + event.get("message", "") or "", details=event.get("details") or {} + ) if etype == "raw_response": data = event.get("data") or {}