Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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" && \
Expand Down
2 changes: 1 addition & 1 deletion QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Open <http://localhost:8873>.
`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**.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/)) |
Expand Down
2 changes: 1 addition & 1 deletion demo/agent-server/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions demo/agent-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion demo/agent-server/src/agent_server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}")
2 changes: 1 addition & 1 deletion demo/agent-server/src/agent_server/agents/shared/hr/hr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}")
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions demo/agent-server/src/agent_server/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions demo/agent-server/src/agent_server/roster.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions demo/hexgate-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```
Expand Down
2 changes: 1 addition & 1 deletion demo/hexgate-agent/src/hexgate_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion demo/scripts/run-backends.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions demo/scripts/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
14 changes: 14 additions & 0 deletions front-app/src/runtime/runtimeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
8 changes: 8 additions & 0 deletions proxy-server/src/platform_backend/translators/google_adk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion proxy-server/src/platform_backend/translators/native.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
Loading