diff --git a/.gitignore b/.gitignore index c6a0a8e..701ac3a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ coverage .venv __pycache__ .claude -.env \ No newline at end of file +.env +*.sqlite +*.sqlite-journal \ No newline at end of file diff --git a/CONTRACT.md b/CONTRACT.md index 15d738c..0247358 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -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 @@ -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: @@ -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 @@ -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. @@ -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 diff --git a/Makefile b/Makefile index ea0ccd9..075599e 100644 --- a/Makefile +++ b/Makefile @@ -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). diff --git a/QUICKSTART.md b/QUICKSTART.md index 20faa3d..e6502fc 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -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 diff --git a/demo/agent-server/pytest.ini b/demo/agent-server/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/demo/agent-server/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/demo/agent-server/src/agent_server/memory.py b/demo/agent-server/src/agent_server/memory.py new file mode 100644 index 0000000..c4c1215 --- /dev/null +++ b/demo/agent-server/src/agent_server/memory.py @@ -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 diff --git a/demo/agent-server/src/agent_server/routes/agents.py b/demo/agent-server/src/agent_server/routes/agents.py index f026b65..16ed919 100644 --- a/demo/agent-server/src/agent_server/routes/agents.py +++ b/demo/agent-server/src/agent_server/routes/agents.py @@ -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 """ @@ -16,7 +17,7 @@ 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 @@ -24,6 +25,33 @@ 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 @@ -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 @@ -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}) @@ -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") @@ -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 diff --git a/demo/agent-server/tests/__init__.py b/demo/agent-server/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/agent-server/tests/test_agents_route.py b/demo/agent-server/tests/test_agents_route.py new file mode 100644 index 0000000..03e0382 --- /dev/null +++ b/demo/agent-server/tests/test_agents_route.py @@ -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 diff --git a/demo/agent-server/tests/test_assistant_text.py b/demo/agent-server/tests/test_assistant_text.py new file mode 100644 index 0000000..8a3e409 --- /dev/null +++ b/demo/agent-server/tests/test_assistant_text.py @@ -0,0 +1,61 @@ +"""Unit tests for ``_assistant_text`` — the per-framework helper that extracts +assistant-text deltas from native events so the stream route can record the +reply into conversation memory. It mirrors the text shapes each translator reads +(CONTRACT §6); anything else must contribute nothing.""" + +from __future__ import annotations + +import pytest +from agent_server.routes.agents import _assistant_text + + +def test_native_text_event(): + assert _assistant_text("native", {"type": "text", "text": "hello"}) == "hello" + + +def test_native_non_text_event_is_empty(): + assert _assistant_text("native", {"type": "tool", "id": "t1"}) == "" + + +@pytest.mark.parametrize("framework", ["langchain", "langgraph", "deepagents"]) +def test_langchain_family_chat_model_stream(framework: str): + ev = {"event": "on_chat_model_stream", "data": {"chunk": {"content": "chunk"}}} + assert _assistant_text(framework, ev) == "chunk" + + +def test_langchain_non_string_content_is_empty(): + ev = {"event": "on_chat_model_stream", "data": {"chunk": {"content": [{"x": 1}]}}} + assert _assistant_text("langchain", ev) == "" + + +def test_langchain_other_event_is_empty(): + assert _assistant_text("langchain", {"event": "on_tool_start"}) == "" + + +def test_openai_agents_output_text_delta(): + ev = {"type": "raw_response", "data": {"type": "response.output_text.delta", "delta": "tok"}} + assert _assistant_text("openai-agents", ev) == "tok" + + +def test_openai_agents_other_event_is_empty(): + ev = {"type": "raw_response", "data": {"type": "response.completed"}} + assert _assistant_text("openai-agents", ev) == "" + + +def test_google_adk_joins_part_texts(): + ev = {"content": {"parts": [{"text": "a"}, {"text": "b"}, {"foo": "ignored"}]}} + assert _assistant_text("google-adk", ev) == "ab" + + +def test_google_adk_turn_complete_marker_is_skipped(): + # Avoid doubling: the final turn_complete frame re-sends accumulated text. + ev = {"turn_complete": True, "content": {"parts": [{"text": "whole reply"}]}} + assert _assistant_text("google-adk", ev) == "" + + +def test_unknown_framework_is_empty(): + assert _assistant_text("mystery", {"type": "text", "text": "x"}) == "" + + +def test_non_dict_event_is_empty(): + assert _assistant_text("native", "not a dict") == "" diff --git a/demo/agent-server/tests/test_memory.py b/demo/agent-server/tests/test_memory.py new file mode 100644 index 0000000..08df06c --- /dev/null +++ b/demo/agent-server/tests/test_memory.py @@ -0,0 +1,70 @@ +"""Unit tests for the in-process conversation memory (``agent_server.memory``). + +The proxy sends only the new turn (CONTRACT §5); this module is the per-conversation +store the stream route appends to and replays. These cover the three functions and +their edge cases (cold ids, empty content, idempotent forget). +""" + +from __future__ import annotations + +import pytest +from agent_server import memory + + +@pytest.fixture(autouse=True) +def _clean_store(): + """Each test starts with an empty store (module-level global).""" + memory._store.clear() + yield + memory._store.clear() + + +def test_history_is_empty_for_cold_id(): + assert memory.history("never-seen") == [] + + +def test_history_none_id_is_empty(): + assert memory.history(None) == [] + + +def test_append_then_history_round_trips_in_order(): + memory.append("c1", "user", "hello") + memory.append("c1", "assistant", "hi there") + memory.append("c1", "user", "bye") + assert memory.history("c1") == [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, + {"role": "user", "content": "bye"}, + ] + + +def test_history_returns_a_copy_not_the_internal_list(): + memory.append("c1", "user", "hello") + snapshot = memory.history("c1") + snapshot.append({"role": "user", "content": "mutation"}) + # Mutating the returned list must not leak back into the store. + assert memory.history("c1") == [{"role": "user", "content": "hello"}] + + +def test_conversations_are_isolated_by_id(): + memory.append("a", "user", "in a") + memory.append("b", "user", "in b") + assert memory.history("a") == [{"role": "user", "content": "in a"}] + assert memory.history("b") == [{"role": "user", "content": "in b"}] + + +def test_append_noops_on_missing_id_or_empty_content(): + memory.append(None, "user", "dropped") + memory.append("c1", "user", "") # empty content is a no-op + assert memory.history("c1") == [] + assert memory._store == {} + + +def test_forget_drops_memory_and_reports_whether_anything_was_stored(): + memory.append("c1", "user", "hello") + assert memory.forget("c1") is True + assert memory.history("c1") == [] + # Idempotent: a second forget (or an unknown id) returns False, never raises. + assert memory.forget("c1") is False + assert memory.forget("never-seen") is False + assert memory.forget(None) is False diff --git a/demo/hexgate-agent/src/hexgate_agent/app.py b/demo/hexgate-agent/src/hexgate_agent/app.py index a6765d1..82096a3 100644 --- a/demo/hexgate-agent/src/hexgate_agent/app.py +++ b/demo/hexgate-agent/src/hexgate_agent/app.py @@ -41,8 +41,13 @@ ] _BY_ID = {a["id"]: a for a in AGENTS} +# Conversation memory (CONTRACT.md §5): the proxy sends only the new turn, so we +# own the transcript, keyed by conversation_id. In-process; swap for a durable +# store in production. +_MEMORY: dict[str, list[dict[str, str]]] = {} -# ── The five contract endpoints ───────────────────────────────────────────── + +# ── The contract endpoints ────────────────────────────────────────────────── router = APIRouter(prefix="/agents", tags=["agents"]) @@ -70,18 +75,33 @@ async def stream(agent_id: str, body: dict[str, Any], request: Request): input = body.get("input") or {} context = body.get("context") or {} framework = _BY_ID[agent_id]["framework"] + conversation_id = context.get("conversation_id") + + # We own memory: the proxy sent only the new turn. Append it, then run the + # agent against the full transcript for this conversation. + if conversation_id: + for m in input.get("messages") or []: + if isinstance(m, dict) and m.get("role") == "user": + _MEMORY.setdefault(conversation_id, []).append( + {"role": "user", "content": str(m.get("content", ""))} + ) + history = {"messages": list(_MEMORY.get(conversation_id, []))} # Register a cancel flag the /cancel route can flip mid-stream. cancel = asyncio.Event() request.app.state.runs[run_id] = cancel async def event_source() -> AsyncIterator[bytes]: + reply_parts: list[str] = [] try: async for ev in run_hexgate_agent( - input=input, context=context, cancel=cancel + input=history, context=context, cancel=cancel ): if cancel.is_set() or await request.is_disconnected(): return + # hexgate streams assistant text as block_delta events. + if ev.get("event_type") == "block_delta" and ev.get("text"): + reply_parts.append(ev["text"]) # Each frame: data: {"framework": "hexgate", "event": } frame = {"framework": framework, "event": ev} yield f"data: {json.dumps(frame, separators=(',', ':'))}\n\n".encode() @@ -93,6 +113,11 @@ async def event_source() -> AsyncIterator[bytes]: yield f"data: {json.dumps(err)}\n\n".encode() finally: request.app.state.runs.pop(run_id, None) + reply = "".join(reply_parts).strip() + if conversation_id and reply: + _MEMORY.setdefault(conversation_id, []).append( + {"role": "assistant", "content": reply} + ) return StreamingResponse(event_source(), media_type="text/event-stream") @@ -107,6 +132,17 @@ async def cancel(agent_id: str, body: dict[str, Any], request: Request) -> dict: return {"cancelled": True} +@router.post("/{agent_id}/forget") # §5 +async def forget(agent_id: str, body: dict[str, Any] | None = None) -> dict: + """Erase a conversation's memory (the proxy calls this on conversation + delete). Idempotent — an unknown id is still 200.""" + if agent_id not in _BY_ID: + raise HTTPException(status_code=404, detail=f"Unknown agent '{agent_id}'") + conversation_id = (body or {}).get("conversation_id") + forgotten = _MEMORY.pop(conversation_id, None) is not None if conversation_id else False + return {"forgotten": forgotten} + + @router.post("/{agent_id}/actions/{action_name}") # §5b (optional) async def invoke_action( agent_id: str, action_name: str, body: dict[str, Any] | None = None diff --git a/demo/scripts/verify_backend.py b/demo/scripts/verify_backend.py index cedfbd1..c6dd027 100644 --- a/demo/scripts/verify_backend.py +++ b/demo/scripts/verify_backend.py @@ -259,6 +259,25 @@ async def consume() -> None: r.fail("cancel returns {cancelled: bool}", json.dumps(cancelled_resp)[:60]) +async def check_forget(c: httpx.AsyncClient, r: Report, agent_id: str) -> None: + section(f"§5 POST /agents/{agent_id}/forget — memory lifecycle") + resp = await c.post( + f"/agents/{agent_id}/forget", json={"conversation_id": uuid.uuid4().hex} + ) + if resp.status_code != 200: + r.fail("forget an unknown conversation -> 200", f"got {resp.status_code}") + return + try: + data = resp.json() + except json.JSONDecodeError: + r.fail("forget returns JSON") + return + if isinstance(data, dict) and isinstance(data.get("forgotten"), bool): + r.ok("forget returns {forgotten: bool}") + else: + r.fail("forget returns {forgotten: bool}", json.dumps(data)[:60]) + + async def check_actions(c: httpx.AsyncClient, r: Report, agent_id: str, ui_text: str) -> None: section(f"§5b POST /agents/{agent_id}/actions/{{name}} — widget actions") # Strip YAML comments so wiring mentioned in prose doesn't count as real. @@ -311,6 +330,7 @@ async def main() -> int: ui_text = await check_ui(c, r, agent_id) await check_stream(c, r, agent_id) await check_cancel(c, r, agent_id) + await check_forget(c, r, agent_id) await check_actions(c, r, agent_id, ui_text) print( diff --git a/demo/starter-agent/src/starter_agent/app.py b/demo/starter-agent/src/starter_agent/app.py index f4d39e9..5891298 100644 --- a/demo/starter-agent/src/starter_agent/app.py +++ b/demo/starter-agent/src/starter_agent/app.py @@ -10,12 +10,20 @@ GET /agents/{id}/ui §4 per-agent ui.yaml (text/yaml) POST /agents/{id}/stream §5 SSE run (framework-tagged events) POST /agents/{id}/cancel §5 body {run_id} -> {cancelled} + POST /agents/{id}/forget §5 body {conversation_id} -> {forgotten} POST /agents/{id}/actions/{name} §5b widget action / data source (optional) You forward your framework's **native** events tagged with `framework`; the proxy synthesizes run_start/run_end, ids, sequence numbers, and block lifecycle. -You never construct any of that. See demo/CONTRACT.md for the full spec and -demo/agent-server/ for a richer reference that exercises every framework. +You never construct any of that. + +**You own conversation memory** (CONTRACT.md §5): the proxy sends only the new +user turn, keyed by `context.conversation_id`. This template keeps a dict of +transcripts (`_MEMORY`) — append the turn, run against the full history, record +the reply, and drop it on `/forget`. Swap the dict for your own store. + +See demo/CONTRACT.md for the full spec and demo/agent-server/ for a richer +reference that exercises every framework. """ from __future__ import annotations @@ -69,7 +77,12 @@ async def run_agent( *, input: dict[str, Any], context: dict[str, Any], cancel: asyncio.Event ) -> AsyncIterator[dict]: + # `input.messages` here is the FULL transcript the server layer rebuilt from + # `_MEMORY` (the proxy itself sent only the new turn). A real agent feeds + # this history to its model; the echo just reads the latest line + turn count + # so multi-turn memory is observable. query = _last_user_text(input) + turns = sum(1 for m in (input.get("messages") or []) if m.get("role") == "user") # Provider API keys live in THIS backend's environment (e.g. OPENAI_API_KEY), # never in the request — read them with os.getenv where you call your model. @@ -79,7 +92,7 @@ async def run_agent( files = (context or {}).get("files") or [] files_note = f" [{len(files)} file(s) attached]" if files else "" - reply = f"You said: {query}{files_note}" + reply = f"You said: {query}{files_note} (turn {turns})" for word in reply.split(" "): if cancel.is_set(): @@ -90,7 +103,13 @@ async def run_agent( yield {"type": "done"} -# ── The five contract endpoints ───────────────────────────────────────────── +# ── Conversation memory (you own it) ──────────────────────────────────────── +# conversation_id -> running transcript. In-process, so a restart forgets it — +# fine for a template; swap for Redis / a DB / your framework's session store. +_MEMORY: dict[str, list[dict[str, str]]] = {} + + +# ── The contract endpoints ────────────────────────────────────────────────── router = APIRouter(prefix="/agents", tags=["agents"]) @@ -118,16 +137,30 @@ async def stream(agent_id: str, body: dict[str, Any], request: Request): input = body.get("input") or {} context = body.get("context") or {} framework = _BY_ID[agent_id]["framework"] + conversation_id = context.get("conversation_id") + + # We own memory: the proxy sent only the new turn. Append it, then run the + # agent against the full transcript we've accumulated for this conversation. + if conversation_id: + for m in input.get("messages") or []: + if isinstance(m, dict) and m.get("role") == "user": + _MEMORY.setdefault(conversation_id, []).append( + {"role": "user", "content": str(m.get("content", ""))} + ) + history = {"messages": list(_MEMORY.get(conversation_id, []))} # Register a cancel flag the /cancel route can flip mid-stream. cancel = asyncio.Event() request.app.state.runs[run_id] = cancel async def event_source() -> AsyncIterator[bytes]: + reply_parts: list[str] = [] try: - async for ev in run_agent(input=input, context=context, cancel=cancel): + async for ev in run_agent(input=history, context=context, cancel=cancel): if cancel.is_set() or await request.is_disconnected(): return + if ev.get("type") == "text": + reply_parts.append(ev.get("text", "")) # Each frame: data: {"framework": "...", "event": } frame = {"framework": framework, "event": ev} yield f"data: {json.dumps(frame, separators=(',', ':'))}\n\n".encode() @@ -136,6 +169,12 @@ async def event_source() -> AsyncIterator[bytes]: yield f"data: {json.dumps(err)}\n\n".encode() finally: request.app.state.runs.pop(run_id, None) + # Record the assistant reply so the next turn sees it. + reply = "".join(reply_parts).strip() + if conversation_id and reply: + _MEMORY.setdefault(conversation_id, []).append( + {"role": "assistant", "content": reply} + ) return StreamingResponse(event_source(), media_type="text/event-stream") @@ -150,6 +189,17 @@ async def cancel(agent_id: str, body: dict[str, Any], request: Request) -> dict: return {"cancelled": True} +@router.post("/{agent_id}/forget") # §5 +async def forget(agent_id: str, body: dict[str, Any] | None = None) -> dict: + """Erase a conversation's memory — the proxy calls this when the user deletes + a conversation. Idempotent: an unknown id is still 200.""" + if agent_id not in _BY_ID: + raise HTTPException(status_code=404, detail=f"Unknown agent '{agent_id}'") + conversation_id = (body or {}).get("conversation_id") + forgotten = _MEMORY.pop(conversation_id, None) is not None if conversation_id else False + return {"forgotten": forgotten} + + @router.post("/{agent_id}/actions/{action_name}") # §5b (optional) async def invoke_action( agent_id: str, action_name: str, body: dict[str, Any] | None = None diff --git a/proxy-server/.env.example b/proxy-server/.env.example index fd32fd2..065e56b 100644 --- a/proxy-server/.env.example +++ b/proxy-server/.env.example @@ -1,11 +1,11 @@ # Local-dev environment. Copy to `.env` and edit. Never commit `.env`. -PLATFORM_DATABASE_URL=postgresql+asyncpg://platform:platform@127.0.0.1:5433/platform +# Default store: a local SQLite file — no server to deploy. For multi-worker / +# high-concurrency, install the `postgres` extra and use a postgresql+asyncpg:// URL: +# PLATFORM_DATABASE_URL=postgresql+asyncpg://platform:platform@127.0.0.1:5433/platform +PLATFORM_DATABASE_URL=sqlite+aiosqlite:///./hexa.sqlite PLATFORM_JWT_SECRET=change-me-to-a-long-random-string -PLATFORM_RUNTIME_URL=http://127.0.0.1:8080 +PLATFORM_AGENT_BACKEND_URL=http://127.0.0.1:8880 PLATFORM_HOST=127.0.0.1 -PLATFORM_PORT=8000 +PLATFORM_PORT=8800 PLATFORM_LOG_LEVEL=info - -# Tests use a separate database (created/dropped by the test fixture). -PLATFORM_TEST_DATABASE_URL=postgresql+asyncpg://platform:platform@127.0.0.1:5433/platform_test diff --git a/proxy-server/docker-compose.yml b/proxy-server/docker-compose.yml index d52f3af..63a3906 100644 --- a/proxy-server/docker-compose.yml +++ b/proxy-server/docker-compose.yml @@ -1,3 +1,7 @@ +# OPTIONAL. The proxy defaults to a local SQLite file (no server needed). Use +# this only to run on Postgres: `docker compose up -d`, install the proxy's +# `postgres` extra, and set +# PLATFORM_DATABASE_URL=postgresql+asyncpg://platform:platform@127.0.0.1:5433/platform services: postgres: image: postgres:16 diff --git a/proxy-server/pyproject.toml b/proxy-server/pyproject.toml index 1172f42..61678c7 100644 --- a/proxy-server/pyproject.toml +++ b/proxy-server/pyproject.toml @@ -9,11 +9,10 @@ dependencies = [ "pydantic[email]>=2.7", "pydantic-settings>=2.4", "sqlalchemy[asyncio]>=2.0", - "asyncpg>=0.29", + "aiosqlite>=0.20", # default store: a single SQLite file, no server to deploy "alembic>=1.13", "argon2-cffi>=23.1", "pyjwt>=2.9", - "cryptography>=43", "httpx>=0.27", "sse-starlette>=2.1", "python-multipart>=0.0.9", @@ -22,10 +21,12 @@ dependencies = [ ] [project.optional-dependencies] +# Postgres is optional — install this extra and set a postgresql+asyncpg:// URL +# to scale past SQLite's single-writer limit (multi-worker / high concurrency). +postgres = ["asyncpg>=0.29"] dev = [ "pytest>=8", "pytest-asyncio>=0.23", - "aiosqlite>=0.20", "ruff>=0.6", ] diff --git a/proxy-server/src/platform_backend/config.py b/proxy-server/src/platform_backend/config.py index 39ccf39..171df47 100644 --- a/proxy-server/src/platform_backend/config.py +++ b/proxy-server/src/platform_backend/config.py @@ -18,8 +18,13 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="PLATFORM_", extra="ignore") database_url: str = Field( - default="postgresql+asyncpg://platform:platform@127.0.0.1:5433/platform", - description="Async SQLAlchemy URL for the primary database.", + default="sqlite+aiosqlite:///./hexa.sqlite", + description=( + "Async SQLAlchemy URL for the primary database. Defaults to a local " + "SQLite file — no server to deploy. For multi-worker / high-concurrency " + "deployments, install the `postgres` extra and set a " + "`postgresql+asyncpg://…` URL." + ), ) jwt_secret: str = Field( default="dev-only-change-me-dev-only-change-me", diff --git a/proxy-server/src/platform_backend/routes/chat.py b/proxy-server/src/platform_backend/routes/chat.py index 09dfb68..19dd37a 100644 --- a/proxy-server/src/platform_backend/routes/chat.py +++ b/proxy-server/src/platform_backend/routes/chat.py @@ -122,17 +122,6 @@ def _autotitle(text: str) -> str: return line[:57].rstrip() + "…" -async def _assemble_history( - session: AsyncSession, conv_id: uuid.UUID -) -> list[dict[str, str]]: - result = await session.execute( - select(Message.role, Message.content) - .where(Message.conversation_id == conv_id) - .order_by(Message.created_at) - ) - return [{"role": role, "content": content} for role, content in result.all()] - - # --------------------------------------------------------------------------- # Chat (streaming) # --------------------------------------------------------------------------- @@ -164,12 +153,14 @@ async def post_message( if is_first_message and not conv.title: conv.title = _autotitle(body.content) await session.commit() - # Refresh so the assembled history below includes this row's timestamp - # ordering reliably. await session.refresh(user_message) await session.refresh(conv) - history = await _assemble_history(session, conv.id) + # The proxy forwards ONLY the new user turn — the agent owns conversation + # memory, keyed by `context.conversation_id` (CONTRACT §5). The user + + # assistant rows we persist are the *display* transcript (sidebar / reload), + # never the model context. + turn = [{"role": "user", "content": body.content}] # Link any newly-attached files to the conversation, then forward ALL of the # conversation's files (attachments persist across turns). @@ -216,7 +207,7 @@ async def post_message( agent_id = conv.agent_id runtime_body = { - "input": {"messages": history}, + "input": {"messages": turn}, "run_id": run_id, "context": { "conversation_id": str(conv.id), @@ -234,7 +225,7 @@ async def post_message( }, } - input_payload = {"messages": history} + input_payload = {"messages": turn} emitter = RunEmitter(run_id, agent_id=agent_id) _active_runs[conv.id] = run_id diff --git a/proxy-server/src/platform_backend/routes/conversations.py b/proxy-server/src/platform_backend/routes/conversations.py index 4bf5d8b..9af5aef 100644 --- a/proxy-server/src/platform_backend/routes/conversations.py +++ b/proxy-server/src/platform_backend/routes/conversations.py @@ -8,12 +8,14 @@ from __future__ import annotations +import logging import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession +from .. import runtime_client from ..access import can_access from ..auth.deps import current_user from ..db import get_session @@ -34,6 +36,7 @@ from ..schemas.message import MessageOut router = APIRouter(prefix="/conversations", tags=["conversations"]) +logger = logging.getLogger("platform_backend.conversations") async def _get_owned( @@ -124,8 +127,16 @@ async def delete_conversation( session: AsyncSession = Depends(get_session), ) -> None: conv = await _get_owned(session, user.id, conv_id) + agent_id = conv.agent_id await session.delete(conv) await session.commit() + # The agent owns this conversation's memory (CONTRACT §5) — tell it to + # forget. Best-effort: the proxy-side delete already succeeded, so a backend + # that's down or doesn't implement /forget must not surface as an error. + try: + await runtime_client.forget(agent_id, str(conv_id)) + except Exception: # noqa: BLE001 + logger.warning("forget failed for conversation %s (agent %r)", conv_id, agent_id) @router.get("/{conv_id}/messages", response_model=list[MessageOut]) diff --git a/proxy-server/src/platform_backend/runtime_client.py b/proxy-server/src/platform_backend/runtime_client.py index e4e32f0..e6991ef 100644 --- a/proxy-server/src/platform_backend/runtime_client.py +++ b/proxy-server/src/platform_backend/runtime_client.py @@ -88,6 +88,20 @@ async def cancel(agent_id: str, run_id: str) -> dict[str, Any]: return r.json() +async def forget(agent_id: str, conversation_id: str) -> dict[str, Any]: + """Tell the backend to erase a conversation's memory (CONTRACT §5). + + Called when the user deletes a conversation. Best-effort: the agent owns + the memory, so a failure here must not roll back the proxy-side delete — + the caller swallows errors. + """ + r = await _client_or_raise().post( + f"/agents/{agent_id}/forget", json={"conversation_id": conversation_id} + ) + r.raise_for_status() + return r.json() + + async def invoke_action( agent_id: str, action_name: str, args: dict[str, Any] | None = None ) -> tuple[int, Any]: diff --git a/proxy-server/tests/test_chat.py b/proxy-server/tests/test_chat.py index 034bc81..abc6cfc 100644 --- a/proxy-server/tests/test_chat.py +++ b/proxy-server/tests/test_chat.py @@ -246,7 +246,8 @@ async def test_second_message_keeps_title_and_extends_history( async for _ in resp.aiter_raw(): pass - # Second message: runtime gets THREE messages back (user, assistant, user). + # Second message: the proxy forwards ONLY the new turn (the agent owns + # conversation memory now), not the prior transcript. mock_runtime["rule"] = lambda r: (200, _build_run(["second reply"])) async with client.stream( "POST", @@ -258,11 +259,17 @@ async def test_second_message_keeps_title_and_extends_history( pass body = json.loads(mock_runtime["requests"][-1]["body"]) - messages = body["input"]["messages"] - assert [m["role"] for m in messages] == ["user", "assistant", "user"] - assert messages[0]["content"] == "First turn" - assert messages[1]["content"] == "first reply" - assert messages[2]["content"] == "Second turn" + assert body["input"]["messages"] == [{"role": "user", "content": "Second turn"}] + + # …but the DISPLAY transcript the proxy stores still extends to all four + # messages, in order (this is what the UI renders on reload). + rows = (await client.get(f"/conversations/{cid}/messages", headers=h)).json() + assert [(m["role"], m["content"]) for m in rows] == [ + ("user", "First turn"), + ("assistant", "first reply"), + ("user", "Second turn"), + ("assistant", "second reply"), + ] # Title is locked in from the first turn. conv = await session.get(Conversation, uuid.UUID(cid)) diff --git a/proxy-server/tests/test_conversations.py b/proxy-server/tests/test_conversations.py index ca1d7d4..cf5aea2 100644 --- a/proxy-server/tests/test_conversations.py +++ b/proxy-server/tests/test_conversations.py @@ -2,11 +2,40 @@ from __future__ import annotations +import json + +import httpx +import pytest from httpx import AsyncClient +from platform_backend import runtime_client from ._helpers import signup +@pytest.fixture +def capture_runtime(): + """Install a mock runtime client recording every request. The test picks the + reply via ``state["response"]`` (default ``200 {forgotten: true}``) so we can + drive both the happy path and a backend failure without a live agent-server.""" + state: dict = { + "requests": [], + "response": httpx.Response(200, json={"forgotten": True}), + } + + def handler(request: httpx.Request) -> httpx.Response: + state["requests"].append( + {"method": request.method, "path": request.url.path, "body": request.content} + ) + return state["response"] + + transport = httpx.MockTransport(handler) + runtime_client.set_client( + httpx.AsyncClient(base_url="http://runtime", transport=transport) + ) + yield state + runtime_client._client = None + + async def test_conversation_crud(client: AsyncClient) -> None: h = (await signup(client))["headers"] @@ -40,6 +69,49 @@ async def test_conversation_crud(client: AsyncClient) -> None: assert r.json() == [] +async def test_delete_conversation_tells_agent_to_forget( + client: AsyncClient, capture_runtime: dict +) -> None: + """Deleting a conversation forwards a single `POST /agents/{id}/forget` with + the conversation id — the agent owns the memory (CONTRACT §5).""" + h = (await signup(client))["headers"] + cid = ( + await client.post("/conversations", json={"agent_id": "probe"}, headers=h) + ).json()["id"] + + r = await client.delete(f"/conversations/{cid}", headers=h) + assert r.status_code == 204 + + forgets = [ + req for req in capture_runtime["requests"] if req["path"] == "/agents/probe/forget" + ] + assert len(forgets) == 1 + assert forgets[0]["method"] == "POST" + assert json.loads(forgets[0]["body"]) == {"conversation_id": cid} + + +async def test_delete_succeeds_even_if_forget_fails( + client: AsyncClient, capture_runtime: dict +) -> None: + """Forget is best-effort: a backend that's down or 500s must not roll back + the proxy-side delete (CONTRACT §5).""" + capture_runtime["response"] = httpx.Response(500, text="backend down") + h = (await signup(client))["headers"] + cid = ( + await client.post("/conversations", json={"agent_id": "probe"}, headers=h) + ).json()["id"] + + r = await client.delete(f"/conversations/{cid}", headers=h) + assert r.status_code == 204 # delete still committed despite the failed forget + + # The conversation really is gone on the proxy side. + assert (await client.get("/conversations", headers=h)).json() == [] + # ...and we did at least attempt the forget. + assert any( + req["path"] == "/agents/probe/forget" for req in capture_runtime["requests"] + ) + + async def test_create_in_folder(client: AsyncClient) -> None: h = (await signup(client))["headers"]