Skip to content
Open
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ coverage
.venv
__pycache__
.claude
.env
.env
*.sqlite
*.sqlite-journal
42 changes: 40 additions & 2 deletions CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ All paths are relative to your backend's base URL
| `GET /agents/{id}/ui` | per-agent `ui.yaml` (`text/yaml`) with `page.main_color` + widgets |
| `POST /agents/{id}/stream` | SSE run — framework-tagged native events (§5) |
| `POST /agents/{id}/cancel` | body `{run_id}` → `{cancelled: bool}` |
| `POST /agents/{id}/forget` | body `{conversation_id}` → `{forgotten: bool}` — erase that conversation's memory (§5) |
| `POST /agents/{id}/actions/{name}` | widget action / data source → `{result}` (§5b) |

Unknown `{id}` → `404`. (§3/§4 — roster and `ui.yaml` — are unchanged from the
Expand Down Expand Up @@ -79,6 +80,27 @@ previous revision; see those sections at the end.)
**NEVER** includes email, password hash, or any internal identifier. An agent
backend that doesn't use this can ignore the block.

### Conversation memory (the backend owns it)

HexUI does **not** manage conversation memory. Each `stream` carries only the
new user turn; the proxy never sends prior messages. **Your backend owns the
conversation context, keyed by `(context.user.id, context.conversation_id)`** —
you decide what prior turns to feed the model (full history, a window, a
summary, retrieval, …). Rules:

- **Cold ids start fresh.** Accept a `conversation_id` you've never seen and
begin a new conversation — never error on an unknown id.
- **Per `(user, agent, conversation)`.** A `conversation_id` never spans two
agents; switching agents starts a new thread.
- **You own durability.** If you keep memory in-process only, multi-turn context
is lost on restart (HexUI still shows the past transcript it stored for
display, but your model won't have it). Persist if that matters to you.
- **`files` / context items are not memory** — they arrive in full each turn
(above); treat them as the current attachments.

> HexUI still stores the user-visible transcript for its own sidebar / reload —
> but that display log is never sent to you and is not your memory.

### Response — framework-tagged native events

`Content-Type: text/event-stream`. Each event is one SSE frame whose `data:` is:
Expand All @@ -102,6 +124,16 @@ stream is open. Stop producing events and end the stream — the proxy finalizes
and persists the partial text. Return `{cancelled: true}` if the run was found,
`false` otherwise.

### Forget (memory lifecycle)

When the user deletes a conversation, the proxy calls
`POST /agents/{id}/forget` with `{"conversation_id": "..."}`. Erase that
conversation's memory and return `{forgotten: true}` (or `false` if you had
nothing for it). Idempotent — forgetting an unknown id is still `200`. This is
the only way the user's "delete conversation" can reach the memory you own, so
implement it for any backend that stores conversation state (covers
"clear history" and right-to-erasure).

---

## 5b. Widget behavior — actions & data sources
Expand Down Expand Up @@ -223,8 +255,12 @@ For each run the proxy:
- emits `run_end` at end-of-stream, accumulating the final assistant message;
- frames every synthesized event in the internal hexa SSE schema and pipes it to
the browser, where the frontend bridge maps it to UI widget events;
- persists user + assistant messages (with `run_id`), bumps `updated_at`,
auto-titles new conversations.
- persists user + assistant messages (with `run_id`) **as the display transcript
only**, bumps `updated_at`, auto-titles new conversations.

What the proxy does **not** do: it does not manage conversation memory. It sends
you only the new turn and calls `/forget` when a conversation is deleted — the
model context is yours to own (see "Conversation memory" in §5).

The rich internal schema is the proxy-internal [`hexa-events`](packages/hexa-events/)
package; developers never see or depend on it.
Expand All @@ -240,6 +276,8 @@ package; developers never see or depend on it.
- [ ] `framework` is one of the supported values (or `native`).
- [ ] `POST /agents/{id}/cancel` with `{run_id}` stops the run and returns `{cancelled: bool}`.
- [ ] `POST /agents/{id}/actions/{name}` accepts `{args}` and returns `{result}` (only if the agent's `ui.yaml` uses `action` / `data_source`).
- [ ] The backend owns conversation memory keyed by `conversation_id`, accepts unseen ids, and treats `input.messages` as the new turn only.
- [ ] `POST /agents/{id}/forget` with `{conversation_id}` erases that conversation's memory and returns `{forgotten: bool}`.
- [ ] Provider API keys are read from the backend's own environment — never expected in the request.

The reference [`agent-server/`](demo/agent-server/) passes all of the above for every
Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ register: install-hexgate ## Register the healthcare + devops + itsm + hr agents
--tools agent_server.agents.hr_agent:TOOLS --model gpt-4o-mini

# -- test -------------------------------------------------------------------
test: ## Run the proxy test suite.
test: ## Run the proxy + agent-server test suites.
cd proxy-server && PYTHONPATH=$(PROXY_PATH) .venv/bin/python -m pytest
cd demo/agent-server && PYTHONPATH=$(AGENT_PATH) .venv/bin/python -m pytest

# -- lint / format ----------------------------------------------------------
lint: ## ruff check across every Python package in the repo (shared ruff.toml).
Expand Down
2 changes: 1 addition & 1 deletion QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ lsof -i :8873 # front-app

**Database feels stale.** The default SQLite file lives at `/tmp/hexa_dev.sqlite` — delete it to reset all conversations and users.

**`no such column` / schema errors after a pull.** The proxy brings the SQLite schema to head via Alembic migrations on startup, so a pull that changes the schema is normally picked up on restart. The exception is a SQLite file created before this behavior existed (tables present, no `alembic_version` table): the first migration fails with "table already exists". Fix it once with `rm /tmp/hexa_dev.sqlite` and restart. (Postgres is migrated out-of-band — run `cd proxy-server && alembic upgrade head` after a schema-changing pull.)
**`no such column` / schema errors after a pull.** The proxy brings the SQLite schema to head via Alembic migrations on startup, so a pull that changes the schema is normally picked up on restart. The exception is a SQLite file created before this behavior existed (tables present, no `alembic_version` table): the first migration fails with "table already exists". Fix it once with `rm /tmp/hexa_dev.sqlite` and restart. (Postgres is optional — only if you set a `postgresql+asyncpg://` URL and install the proxy's `postgres` extra; it's migrated out-of-band with `cd proxy-server && alembic upgrade head`.)

## Next steps

Expand Down
3 changes: 3 additions & 0 deletions demo/agent-server/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests
39 changes: 39 additions & 0 deletions demo/agent-server/src/agent_server/memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""In-process per-conversation memory for the reference agents.

The HexUI proxy sends only the **new** user turn (CONTRACT.md §5) — the backend
owns conversation memory. This module is that store for agent-server: a dict
keyed by ``conversation_id`` holding the running transcript. The stream route
(``routes/agents.py``) appends the incoming user turn, hands the agent the full
reconstructed transcript as ``input.messages``, then appends the assistant's
reply once the run finishes.

It is the simplest thing that satisfies the contract — **in-process only**, so a
restart forgets everything. That's fine for a demo; a real backend swaps this
for its own durable store (Redis, a DB, the framework's session service, …)
behind the same three functions. Single asyncio loop, so no locking needed.
"""

from __future__ import annotations

_store: dict[str, list[dict[str, str]]] = {}


def history(conversation_id: str | None) -> list[dict[str, str]]:
"""The full transcript for a conversation (empty for an unknown/cold id)."""
if not conversation_id:
return []
return list(_store.get(conversation_id, []))


def append(conversation_id: str | None, role: str, content: str) -> None:
"""Append one message. No-ops on a missing id or empty content."""
if not conversation_id or not content:
return
_store.setdefault(conversation_id, []).append({"role": role, "content": content})


def forget(conversation_id: str | None) -> bool:
"""Drop a conversation's memory. Returns whether anything was stored."""
if not conversation_id:
return False
return _store.pop(conversation_id, None) is not None
56 changes: 54 additions & 2 deletions demo/agent-server/src/agent_server/routes/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
GET /agents/{id}/ui per-agent ui.yaml (text/yaml)
POST /agents/{id}/stream SSE run
POST /agents/{id}/cancel body {run_id} -> {cancelled}
POST /agents/{id}/forget body {conversation_id} -> {forgotten}
POST /agents/{id}/actions/{name} optional widget action
"""

Expand All @@ -16,14 +17,41 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response, StreamingResponse

from .. import protocol
from .. import memory, protocol
from ..actions import run_action
from ..agents.select import select_agent
from ..roster import AGENTS, get_agent, read_ui

router = APIRouter(prefix="/agents", tags=["agents"])


def _assistant_text(framework: str, event: dict) -> str:
"""Best-effort assistant-text delta from a native event, so the route can
record the reply into conversation memory. Mirrors the text shapes each
translator reads (CONTRACT.md §6); anything else contributes nothing."""
if not isinstance(event, dict):
return ""
if framework == "native":
return event.get("text", "") if event.get("type") == "text" else ""
if framework in ("langchain", "langgraph", "deepagents"):
if event.get("event") == "on_chat_model_stream":
chunk = (event.get("data") or {}).get("chunk") or {}
return chunk.get("content", "") if isinstance(chunk.get("content"), str) else ""
return ""
if framework == "openai-agents":
data = event.get("data") or {}
if event.get("type") == "raw_response" and data.get("type") == "response.output_text.delta":
return data.get("delta", "") or ""
return ""
if framework == "google-adk":
# Streamed text deltas; skip the turn_complete marker to avoid doubling.
if event.get("turn_complete"):
return ""
parts = (event.get("content") or {}).get("parts") or []
return "".join(p.get("text", "") for p in parts if isinstance(p, dict) and p.get("text"))
return ""


@router.get("")
async def list_agents() -> list[dict[str, str]]:
return AGENTS
Expand All @@ -47,6 +75,16 @@ async def stream(agent_id: str, body: dict[str, Any], request: Request):
run_id = body.get("run_id") or uuid.uuid4().hex
input = body.get("input") or {}
context = body.get("context") or {}
conversation_id = context.get("conversation_id")

# The proxy sends only the new turn (CONTRACT.md §5); WE own the memory.
# Append the incoming user turn, then hand the agent the full reconstructed
# transcript as `input.messages` (so the agents stay framework-agnostic and
# unchanged). Idempotency on retries is left out for brevity — see §5 rule 5.
for m in (input.get("messages") or []):
if isinstance(m, dict) and m.get("role") == "user":
memory.append(conversation_id, "user", str(m.get("content", "")))
full_input = {"messages": memory.history(conversation_id)}

cancel_event = asyncio.Event()
runs: dict[str, asyncio.Event] = request.app.state.runs
Expand All @@ -56,12 +94,14 @@ async def stream(agent_id: str, body: dict[str, Any], request: Request):
framework = getattr(agent, "framework", "native")

async def event_source():
reply_parts: list[str] = []
try:
async for ev in agent.run(input=input, context=context):
async for ev in agent.run(input=full_input, context=context):
# Stop on cancel/disconnect. We just end the stream — the proxy
# synthesizes run_end and persists whatever it accumulated.
if cancel_event.is_set() or await request.is_disconnected():
return
reply_parts.append(_assistant_text(framework, ev))
# Tag each native event with its framework so the proxy picks
# the right translator.
yield protocol.to_sse({"framework": framework, "event": ev})
Expand All @@ -71,6 +111,8 @@ async def event_source():
)
finally:
runs.pop(run_id, None)
# Record the assistant reply so the next turn sees it.
memory.append(conversation_id, "assistant", "".join(reply_parts).strip())

return StreamingResponse(event_source(), media_type="text/event-stream")

Expand All @@ -85,6 +127,16 @@ async def cancel(agent_id: str, body: dict[str, Any], request: Request) -> dict:
return {"cancelled": True}


@router.post("/{agent_id}/forget")
async def forget(agent_id: str, body: dict[str, Any] | None = None) -> dict:
"""Erase a conversation's memory (CONTRACT.md §5). The proxy calls this when
the user deletes a conversation. Idempotent — an unknown id is still 200."""
if get_agent(agent_id) is None:
raise HTTPException(status_code=404, detail=f"Unknown agent '{agent_id}'")
conversation_id = (body or {}).get("conversation_id")
return {"forgotten": memory.forget(conversation_id)}


@router.post("/{agent_id}/actions/{action_name}")
async def invoke_action(
agent_id: str, action_name: str, body: dict[str, Any] | None = None
Expand Down
Empty file.
138 changes: 138 additions & 0 deletions demo/agent-server/tests/test_agents_route.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Route-level tests for the conversation-memory behaviour added with the
Postgres removal: the stream route owns memory (appends the new turn, replays
the full transcript to the agent, records the reply), and the new
``POST /agents/{id}/forget`` clears it (CONTRACT §5).

We drive the real ASGI app through httpx; one test swaps in a recording agent so
we can assert exactly what ``input.messages`` the agent receives.
"""

from __future__ import annotations

from collections.abc import AsyncIterator
from typing import Any

import pytest
from agent_server import memory, protocol
from agent_server.routes import agents as agents_route
from agent_server.server.app import create_app
from httpx import ASGITransport, AsyncClient


@pytest.fixture(autouse=True)
def _clean_store():
memory._store.clear()
yield
memory._store.clear()


@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
transport = ASGITransport(app=create_app())
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c


class _RecordingAgent:
"""A native agent that records the ``input`` it's handed and emits one text
chunk (so the route has an assistant reply to record into memory)."""

framework = "native"

def __init__(self, captured: list[dict]) -> None:
self._captured = captured

async def run(self, *, input: dict[str, Any], context: dict[str, Any]) -> AsyncIterator[dict]:
self._captured.append(input)
yield protocol.text("reply ")
yield protocol.text("text")


async def _drain_stream(client: AsyncClient, agent_id: str, body: dict) -> int:
"""POST a stream and consume it to completion (so the route's ``finally``
block — which records the assistant reply — runs). Returns the status code."""
async with client.stream("POST", f"/agents/{agent_id}/stream", json=body) as resp:
if resp.status_code == 200:
async for _ in resp.aiter_bytes():
pass
return resp.status_code


async def test_stream_replays_full_transcript_and_records_reply(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
captured: list[dict] = []
monkeypatch.setattr(
agents_route, "select_agent", lambda agent_id, context: _RecordingAgent(captured)
)

ctx = {"conversation_id": "conv-1"}

# Turn 1: agent sees only the new turn; memory now holds user + assistant.
assert (
await _drain_stream(
client,
"probe",
{"input": {"messages": [{"role": "user", "content": "first"}]}, "context": ctx},
)
== 200
)
assert captured[0]["messages"] == [{"role": "user", "content": "first"}]
assert memory.history("conv-1") == [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply text"},
]

# Turn 2: the agent is handed the FULL reconstructed transcript, not just the
# new turn — this is the memory the proxy no longer sends.
assert (
await _drain_stream(
client,
"probe",
{"input": {"messages": [{"role": "user", "content": "second"}]}, "context": ctx},
)
== 200
)
assert captured[1]["messages"] == [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply text"},
{"role": "user", "content": "second"},
]


async def test_stream_unknown_agent_is_404(client: AsyncClient) -> None:
assert await _drain_stream(client, "no-such-agent", {"input": {"messages": []}}) == 404


async def test_forget_clears_memory(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
agents_route, "select_agent", lambda agent_id, context: _RecordingAgent([])
)
await _drain_stream(
client,
"probe",
{
"input": {"messages": [{"role": "user", "content": "hi"}]},
"context": {"conversation_id": "conv-9"},
},
)
assert memory.history("conv-9") # populated by the run above

r = await client.post("/agents/probe/forget", json={"conversation_id": "conv-9"})
assert r.status_code == 200
assert r.json() == {"forgotten": True}
assert memory.history("conv-9") == []


async def test_forget_unknown_conversation_is_200_false(client: AsyncClient) -> None:
"""Idempotent: forgetting an id we never stored is still 200 (CONTRACT §5)."""
r = await client.post("/agents/probe/forget", json={"conversation_id": "never-seen"})
assert r.status_code == 200
assert r.json() == {"forgotten": False}


async def test_forget_unknown_agent_is_404(client: AsyncClient) -> None:
r = await client.post("/agents/no-such-agent/forget", json={"conversation_id": "x"})
assert r.status_code == 404
Loading
Loading