chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# Oracle Agent Spec × Memory × CopilotKit
|
||||
|
||||
A personal **travel concierge** that shows how to use three things together — it searches flights, renders generative UI (flight cards, boarding-pass ticket), and remembers you across sessions:
|
||||
|
||||
- **Oracle Agent Spec** — define the agent once as portable JSON, run it on LangGraph.
|
||||
- **Oracle AI Database / Agent Memory** — durable, cross-session memory via semantic search.
|
||||
- **CopilotKit** — the frontend chat layer, over the open [AG-UI](https://docs.ag-ui.com/) protocol.
|
||||
|
||||
Tell the concierge your travel preferences, come back in a brand-new session, and
|
||||
it still knows them — recalled from Oracle AI Database, not the current chat.
|
||||
|
||||
> 🌐 Try it live: [hosted demo on Railway](https://showcase-oracle-agent-memory-production.up.railway.app)
|
||||
> 📖 Full write-up: [the cookbook recipe](../../../showcase/shell-docs/src/content/docs/cookbook/oracle-agent-spec-memory.mdx)
|
||||
|
||||
## How it works
|
||||
|
||||
```text
|
||||
Next.js + CopilotKit (V2) ──/api/copilotkit──▶ CopilotRuntime (HttpAgent)
|
||||
│ AG-UI (SSE)
|
||||
▼
|
||||
Agent Spec JSON → ag_ui_agentspec (LangGraph)
|
||||
recall_memory · search_flights · book_flight (HITL ClientTool)
|
||||
│ recall + persist
|
||||
▼
|
||||
oracleagentmemory → Oracle AI Database
|
||||
```
|
||||
|
||||
The agent is **defined once** in Agent Spec (`agent/concierge/agent.py`) and run on
|
||||
LangGraph via the `ag_ui_agentspec` adapter. `recall_memory` pulls durable
|
||||
preferences from Oracle Agent Memory before planning; each turn is persisted so new
|
||||
preferences are extracted for next time, and a reconcile pass supersedes outdated facts so an updated preference wins on the next recall. CopilotKit consumes the AG-UI endpoint
|
||||
with an `HttpAgent`, so the agent owns the LLM call.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.12** (required — `oracleagentmemory` ships a cp312-only wheel),
|
||||
[`uv`](https://docs.astral.sh/uv/), Node.js 18+
|
||||
- Docker (for the local Oracle AI Database) or your own Oracle AI Database
|
||||
- `OPENAI_API_KEY` (defaults use OpenAI via litellm)
|
||||
|
||||
> **Heads-up:** the frontend uses CopilotKit **V2 prerelease** builds so Agent
|
||||
> Spec's human-in-the-loop renders, and the `ag_ui_agentspec` adapter is installed
|
||||
> from the `ag-ui` repo (not PyPI). Both are pinned in the manifests.
|
||||
|
||||
## Quickstart
|
||||
|
||||
### 1. Start Oracle AI Database (run from this directory)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f oracle-db # wait for "DATABASE IS READY TO USE"
|
||||
./db/setup-db.sh # create the cookbook DB user (idempotent)
|
||||
```
|
||||
|
||||
First boot takes a few minutes. The `container-registry.oracle.com/database/free`
|
||||
image includes AI Vector Search, which `oracleagentmemory` uses for semantic recall.
|
||||
|
||||
### 2. Run the agent
|
||||
|
||||
```bash
|
||||
cd agent
|
||||
cp .env.example .env # add your OPENAI_API_KEY
|
||||
uv sync
|
||||
uv run uvicorn concierge.server:app --reload --port 8000
|
||||
```
|
||||
|
||||
Health check: `curl localhost:8000/health` → `{"status":"ok"}`.
|
||||
|
||||
### 3. Run the frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.local.example .env.local # optional; defaults to localhost:8000/run
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000.
|
||||
|
||||
## Try it
|
||||
|
||||
1. Tell it: _"I'm vegetarian, I fly from SFO, and I prefer an aisle seat."_
|
||||
2. Click **"+ New thread"** in the left sidebar, then ask: _"Find me a flight to Amsterdam."_
|
||||
3. It recalls your preferences from Oracle (home airport SFO, aisle seat, vegetarian meal)
|
||||
and surfaces flights like **AMS-001 — KLM KL606, nonstop, $740** as clickable flight
|
||||
cards — driven by what it remembered, not what you said in this thread.
|
||||
|
||||
**Book it:** select a flight from the cards (or ask _"Book me flight AMS-001 to Amsterdam"_),
|
||||
then click **Confirm & book** on the confirmation card to get the boarding pass.
|
||||
`book_flight` is a CopilotKit **ClientTool** so the confirm→book step resolves in one agent run.
|
||||
Multi-turn follow-ups in the same thread work too, via a server-side workaround — see Notes below.
|
||||
|
||||
## Tests
|
||||
|
||||
End-to-end Playwright tests drive the real chat UI against the live agent + Oracle
|
||||
AI Database and record video. See [`frontend/e2e/README.md`](frontend/e2e/README.md):
|
||||
|
||||
```bash
|
||||
cd frontend && npm run test:e2e
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **User identity** — defaults to a single `demo-user`. The Agent Spec × AG-UI
|
||||
adapter doesn't forward `forwarded_props`, so to scope memory per real user, set
|
||||
`user_id` from a ContextVar populated by a FastAPI dependency. See
|
||||
`agent/concierge/tools.py`.
|
||||
- **Multi-turn & booking** — `book_flight` is a CopilotKit **ClientTool**
|
||||
(`useHumanInTheLoop`), so the confirm→book step resolves inside a single agent run.
|
||||
Follow-up messages after a server-tool call would otherwise trip an upstream Agent
|
||||
Spec × AG-UI adapter bug (`tool_call_id` correlation); the cookbook works around it in
|
||||
`agent/concierge/server.py` by replacing the adapter's incremental message merge with a
|
||||
full-history replace each turn, so multi-turn conversations work end-to-end. The
|
||||
**"+ New thread"** flow above just proves recall is user-scoped — a fresh thread still
|
||||
remembers you. See
|
||||
[`docs/known-issues/agentspec-multiturn-toolcall-correlation.md`](docs/known-issues/agentspec-multiturn-toolcall-correlation.md).
|
||||
- **Models** — set `CHAT_MODEL`, `MEMORY_LLM_MODEL`, `EMBEDDING_MODEL` in `agent/.env`.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Keep the local virtualenv, caches, and secrets out of the build context.
|
||||
.venv/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,12 @@
|
||||
# ── LLM / embedding provider (litellm-backed; defaults use OpenAI) ────────────
|
||||
OPENAI_API_KEY=sk-...
|
||||
CHAT_MODEL=gpt-5.4-mini # model the Agent Spec agent answers with
|
||||
MEMORY_LLM_MODEL=gpt-5.4-mini # model oracleagentmemory uses to extract memories
|
||||
EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
# ── Oracle AI Database (root docker-compose) ───────────
|
||||
ORACLE_DB_USER=cookbook
|
||||
ORACLE_DB_PASSWORD=cookbook_pw
|
||||
ORACLE_DB_DSN=localhost:1521/FREEPDB1
|
||||
|
||||
LANGGRAPH_CHECKPOINTER=memory # set to "oracle" for durable LangGraph graph state in Oracle
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent service image (Railway). Python 3.12 is required — oracleagentmemory
|
||||
# ships a cp312-only wheel.
|
||||
FROM python:3.12-slim
|
||||
|
||||
# git: the ag_ui_agentspec adapter installs from the ag-ui git repo (see pyproject).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# uv — the project's package manager.
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
# Use the image's system Python 3.12 (don't fetch a managed interpreter).
|
||||
ENV UV_PYTHON_PREFERENCE=only-system UV_PYTHON_DOWNLOADS=never
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies from the lockfile first (cached layer). The project is not
|
||||
# a package (tool.uv.package = false), so only the deps are installed.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-install-project
|
||||
|
||||
# App code.
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
# Bind Railway's $PORT (8000 locally). --no-sync: deps are already installed.
|
||||
CMD ["sh", "-c", "uv run --no-sync uvicorn concierge.server:app --host 0.0.0.0 --port ${PORT:-8000}"]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Declaratively define the travel-concierge agent in Agent Spec and serialize it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from pyagentspec.agent import Agent
|
||||
from pyagentspec.llms import OpenAiCompatibleConfig
|
||||
from pyagentspec.serialization import AgentSpecSerializer
|
||||
|
||||
from .tools import TOOLS
|
||||
|
||||
SYSTEM_PROMPT = """You are a personal flight concierge with long-term memory.
|
||||
|
||||
Start every conversation by calling recall_memory to retrieve the traveler's durable
|
||||
preferences — home airport, seat preference (e.g. aisle), meal preference (e.g.
|
||||
vegetarian), and favorite airlines or destinations. Weave these in naturally; never
|
||||
say "according to my memory" or reveal that you looked something up.
|
||||
|
||||
Use search_flights to find available flights for the requested destination. Present
|
||||
the results clearly, highlighting options that best honor the traveler's recalled
|
||||
preferences (e.g. nonstop if they prefer it, aisle seat availability, vegetarian meal
|
||||
on request, preferred airline or home airport as origin).
|
||||
|
||||
When the traveler has chosen a flight and wants to book it, call book_flight with the
|
||||
chosen flight's id (e.g. 'AMS-001'). The traveler will confirm the booking in the UI
|
||||
before it is finalized — do not ask them to confirm again in chat."""
|
||||
|
||||
|
||||
def build_agent() -> Agent:
|
||||
# Do NOT set api_key here: AgentSpecSerializer treats it as a SensitiveField and
|
||||
# disaggregates it out of the serialized JSON, which then fails to load without a
|
||||
# components_registry ("references ... missing ... api_key"). Leave it unset — the
|
||||
# LangGraph ChatOpenAI reads OPENAI_API_KEY from the environment (load_dotenv in
|
||||
# server.py puts it there).
|
||||
llm = OpenAiCompatibleConfig(
|
||||
name="concierge_llm",
|
||||
model_id=os.getenv("CHAT_MODEL", "gpt-5.4-mini"),
|
||||
url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
|
||||
)
|
||||
return Agent(
|
||||
name="travel_concierge",
|
||||
llm_config=llm,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
tools=TOOLS,
|
||||
human_in_the_loop=True,
|
||||
)
|
||||
|
||||
|
||||
def build_agent_json() -> str:
|
||||
"""Return the Agent Spec JSON string the adapter loads."""
|
||||
return AgentSpecSerializer().to_json(build_agent())
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Flag-gated LangGraph checkpointer for durable Oracle graph state.
|
||||
|
||||
Resolves the LangGraph checkpointer based on the ``LANGGRAPH_CHECKPOINTER``
|
||||
environment variable. When set to ``oracle``, builds a dedicated async Oracle
|
||||
connection pool and an ``AsyncOracleSaver`` (durable graph-state persistence
|
||||
that complements OracleAgentMemory for conversation history). Any other value
|
||||
— or the default when the variable is absent — falls back to an in-memory
|
||||
``MemorySaver`` so the agent works without a database.
|
||||
|
||||
Usage::
|
||||
|
||||
await init_checkpointer() # call once at startup
|
||||
checkpointer = resolve_checkpointer() # call per LangGraph graph build
|
||||
...
|
||||
await close_checkpointer() # call once at shutdown
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import oracledb
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph_oracledb.checkpoint.oracle import AsyncOracleSaver
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level globals – populated by init_checkpointer()
|
||||
# ---------------------------------------------------------------------------
|
||||
_pool = None
|
||||
_saver = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _require(name: str) -> str:
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise RuntimeError(
|
||||
f"Missing required environment variable {name!r}. "
|
||||
"Copy agent/.env.example to agent/.env and fill it in."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _flag() -> str:
|
||||
return os.getenv("LANGGRAPH_CHECKPOINTER", "memory").lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def init_checkpointer() -> None:
|
||||
"""Initialise the Oracle async pool + AsyncOracleSaver when the flag is set.
|
||||
|
||||
Safe to call at startup unconditionally — exits immediately when the flag
|
||||
is not ``oracle``. On any failure the module degrades gracefully to the
|
||||
in-memory saver rather than crashing the server.
|
||||
"""
|
||||
global _pool, _saver
|
||||
|
||||
if _flag() != "oracle":
|
||||
return
|
||||
|
||||
try:
|
||||
_pool = oracledb.create_pool_async(
|
||||
user=_require("ORACLE_DB_USER"),
|
||||
password=_require("ORACLE_DB_PASSWORD"),
|
||||
dsn=_require("ORACLE_DB_DSN"),
|
||||
min=1,
|
||||
max=4,
|
||||
increment=1,
|
||||
)
|
||||
_saver = AsyncOracleSaver(_pool)
|
||||
await _saver.setup()
|
||||
except Exception as exc:
|
||||
print(f"[checkpointer] warning: Oracle checkpointer init failed — degrading to MemorySaver ({exc})")
|
||||
_pool = None
|
||||
_saver = None
|
||||
|
||||
|
||||
async def close_checkpointer() -> None:
|
||||
"""Close the async Oracle pool and reset the module globals.
|
||||
|
||||
Safe to call unconditionally at shutdown (no-op when the pool was never
|
||||
opened or already closed).
|
||||
"""
|
||||
global _pool, _saver
|
||||
|
||||
if _pool is not None:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
_saver = None
|
||||
|
||||
|
||||
def resolve_checkpointer():
|
||||
"""Return the active checkpointer for use in a LangGraph graph build.
|
||||
|
||||
Returns the ``AsyncOracleSaver`` when the flag is ``oracle`` AND the saver
|
||||
was successfully initialised; otherwise returns a fresh ``MemorySaver()``.
|
||||
"""
|
||||
if _flag() == "oracle" and _saver is not None:
|
||||
return _saver
|
||||
return MemorySaver()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Build and cache the OracleAgentMemory client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import os
|
||||
|
||||
import oracledb
|
||||
from oracleagentmemory.core import OracleAgentMemory
|
||||
from oracleagentmemory.core.embedders.embedder import Embedder
|
||||
from oracleagentmemory.core.llms.llm import Llm
|
||||
|
||||
|
||||
def _require(name: str) -> str:
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise RuntimeError(
|
||||
f"Missing required environment variable {name!r}. "
|
||||
"Copy agent/.env.example to agent/.env and fill it in."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def get_memory() -> OracleAgentMemory:
|
||||
"""Return a process-wide singleton memory client (lazily constructed)."""
|
||||
pool = oracledb.create_pool(
|
||||
user=_require("ORACLE_DB_USER"),
|
||||
password=_require("ORACLE_DB_PASSWORD"),
|
||||
dsn=_require("ORACLE_DB_DSN"), # e.g. "localhost:1521/FREEPDB1"
|
||||
min=1,
|
||||
max=4,
|
||||
increment=1,
|
||||
)
|
||||
embedder = Embedder(model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"))
|
||||
llm = Llm(model=os.getenv("MEMORY_LLM_MODEL", "gpt-5.4-mini"))
|
||||
# schema_policy="create_if_necessary" provisions the memory tables on first
|
||||
# run (the cookbook DB user has DB_DEVELOPER_ROLE). Without it, OracleAgentMemory
|
||||
# errors on a fresh database with "Managed DB schema is missing required objects".
|
||||
return OracleAgentMemory(
|
||||
connection=pool,
|
||||
embedder=embedder,
|
||||
llm=llm,
|
||||
schema_policy="create_if_necessary",
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""LLM-driven supersession for durable memories.
|
||||
|
||||
Oracle Agent Memory *accumulates* extracted facts; it doesn't retract an old one
|
||||
when a contradicting newer one arrives. So after a traveler changes a preference
|
||||
("I now fly from Cebu" after an earlier "I fly from SFO"), both coexist and recall
|
||||
keeps surfacing the stale value. This pass asks the memory LLM to identify
|
||||
outdated/duplicate durable facts and deletes them, so the most recent value wins on
|
||||
the next recall. It runs in the background after each turn and degrades to a no-op
|
||||
on any error (we never delete unless the LLM names ids from the candidate set).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
from oracleagentmemory.apis.searchscope import SearchScope
|
||||
|
||||
from .memory import get_memory
|
||||
from .tools import DURABLE_RECORD_TYPES
|
||||
|
||||
_SYSTEM_PROMPT = """You curate a traveler's durable travel preferences stored as facts.
|
||||
You are given a JSON list of facts, each with an "id", a "when" timestamp, and "text".
|
||||
|
||||
Mark a fact for deletion when:
|
||||
- Two or more facts describe the SAME attribute (e.g. home/departure airport, seat
|
||||
preference, meal preference) with the same OR conflicting values — keep ONLY the most
|
||||
recent (latest "when") and delete the older ones.
|
||||
- A fact is a near-duplicate of another — keep the most recent, delete the rest.
|
||||
|
||||
Never delete facts about DISTINCT attributes. When in doubt, keep it.
|
||||
Return strict JSON: {"delete_ids": ["<id>", ...]}. Use only ids present in the input."""
|
||||
|
||||
|
||||
def _client() -> OpenAI:
|
||||
return OpenAI(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
|
||||
)
|
||||
|
||||
|
||||
def reconcile_durable_memories(user_id: str) -> int:
|
||||
"""Delete superseded / duplicate durable memories for ``user_id``.
|
||||
|
||||
Returns the number of records deleted (0 on no-op or any failure).
|
||||
"""
|
||||
memory = get_memory()
|
||||
results = memory.search(
|
||||
query="traveler durable preferences: home airport, seat, meal, airlines, destinations",
|
||||
scope=SearchScope(user_id=user_id),
|
||||
record_types=DURABLE_RECORD_TYPES,
|
||||
max_results=50,
|
||||
)
|
||||
|
||||
facts = []
|
||||
for r in results:
|
||||
rid = getattr(r, "id", None)
|
||||
content = (getattr(r, "content", "") or "").strip()
|
||||
if rid and content:
|
||||
facts.append({"id": rid, "when": str(getattr(r, "timestamp", "")), "text": content})
|
||||
|
||||
if len(facts) < 2:
|
||||
return 0
|
||||
|
||||
valid_ids = {f["id"] for f in facts}
|
||||
try:
|
||||
resp = _client().chat.completions.create(
|
||||
model=os.getenv("MEMORY_LLM_MODEL", "gpt-5.4-mini"),
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": json.dumps(facts, ensure_ascii=False)},
|
||||
],
|
||||
)
|
||||
data = json.loads(resp.choices[0].message.content or "{}")
|
||||
delete_ids = [i for i in data.get("delete_ids", []) if i in valid_ids]
|
||||
except Exception as exc: # never let reconciliation break persistence
|
||||
print(f"[reconcile] skipped (LLM/parse failed: {exc!r})")
|
||||
return 0
|
||||
|
||||
deleted = 0
|
||||
for rid in delete_ids:
|
||||
try:
|
||||
deleted += memory.delete_memory(rid)
|
||||
except Exception as exc:
|
||||
print(f"[reconcile] delete {rid} failed: {exc!r}")
|
||||
if deleted:
|
||||
print(f"[reconcile] superseded {deleted} stale/duplicate memories for {user_id}")
|
||||
return deleted
|
||||
@@ -0,0 +1,338 @@
|
||||
"""FastAPI server: Agent Spec agent on LangGraph over AG-UI, + durable memory.
|
||||
|
||||
We hand-roll the AG-UI streaming route (copied from the adapter's thin
|
||||
`add_agentspec_fastapi_endpoint`) so we can persist each exchange to Oracle
|
||||
Agent Memory — the adapter exposes no post-run hook. Persistence runs as a
|
||||
background task once the run finishes (off the SSE critical path, so the stream
|
||||
closes at RUN_FINISHED); it is fully server-side, and the frontend just streams
|
||||
from /run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import html
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from ag_ui.core import EventType, RunAgentInput, RunErrorEvent
|
||||
from ag_ui.encoder import EventEncoder
|
||||
from ag_ui_agentspec.agent import AgentSpecAgent
|
||||
from ag_ui_agentspec.agentspec_tracing_exporter import EVENT_QUEUE
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from .agent import build_agent_json
|
||||
from .memory import get_memory
|
||||
from .reconcile import reconcile_durable_memories
|
||||
from .tools import DEMO_USER_ID, TOOL_REGISTRY
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# ── Multi-turn fix (upstream adapter workaround) ──────────────────────────────
|
||||
# The ag_ui_agentspec LangGraph runner checkpoints history per thread_id and, on
|
||||
# each turn, tries to append only the client messages whose ids aren't already in
|
||||
# the checkpoint (filter_only_new_messages). But CopilotKit re-sends the *full*
|
||||
# history with ids that never match the checkpoint's, so a second copy of the
|
||||
# assistant(tool_calls)/tool block gets appended; OpenAI then rejects the malformed
|
||||
# sequence on the next turn (400: "a message with role 'tool' must be a response to
|
||||
# a preceeding message with 'tool_calls'"), breaking every follow-up after a server
|
||||
# tool runs. See docs/known-issues/agentspec-multiturn-toolcall-correlation.md.
|
||||
#
|
||||
# Since the client already sends the full, valid history each turn, we replace the
|
||||
# adapter's incremental merge with a full-history *replace*: clear the checkpoint's
|
||||
# messages (RemoveMessage) and use the client's history verbatim. Drop this once the
|
||||
# upstream adapter records ToolExecutionRequests so the ids correlate.
|
||||
from langchain_core.messages import RemoveMessage # noqa: E402
|
||||
from langgraph.graph.message import REMOVE_ALL_MESSAGES # noqa: E402
|
||||
import ag_ui_agentspec.runtimes.langgraph_runner as _lg_runner # noqa: E402
|
||||
|
||||
|
||||
def _repair_dangling_tool_calls(messages: list[dict]) -> list[dict]:
|
||||
"""Synthesize a tool result for any assistant tool_call that has no response.
|
||||
|
||||
book_flight is a client-side HITL tool: calling it interrupts the run and emits
|
||||
an assistant message with a tool_call, then waits for the UI to return a result
|
||||
when the traveler clicks Confirm/Cancel. If they instead send another chat
|
||||
message, that tool_call is left unanswered — and because we forward the client's
|
||||
full history verbatim, OpenAI rejects the next turn (400: "tool_call_ids did not
|
||||
have response messages"). This is the inverse of the duplicate-tool-block issue
|
||||
the history replace already handles (see the comment above).
|
||||
|
||||
For each assistant tool_call with no real tool result, insert a synthetic
|
||||
"not completed" tool result directly after the assistant message so the sequence
|
||||
is valid and the model can answer the new question. In this app the only tool
|
||||
that can dangle is the book_flight HITL — server tools resolve within the run —
|
||||
so the synthetic content is phrased for that case.
|
||||
|
||||
Assumes CopilotKit's normal ordering, where a real tool result immediately
|
||||
follows its assistant tool_calls message: this repairs *missing* results, not a
|
||||
result that has been re-ordered away from its originating call.
|
||||
"""
|
||||
# tool_call_ids that already have a REAL result somewhere in the history.
|
||||
answered = {
|
||||
m["tool_call_id"]
|
||||
for m in messages
|
||||
if m.get("role") == "tool" and m.get("tool_call_id")
|
||||
}
|
||||
repaired: list[dict] = []
|
||||
for m in messages:
|
||||
repaired.append(m)
|
||||
if m.get("role") != "assistant" or not m.get("tool_calls"):
|
||||
continue
|
||||
# De-dupe within THIS message only — a second assistant message carrying the
|
||||
# same unanswered id still needs its own result, so `answered` is never
|
||||
# mutated here (mutating it was the original bug: it suppressed the repair the
|
||||
# next occurrence needed).
|
||||
synthesized: set[str] = set()
|
||||
for tc in m["tool_calls"]:
|
||||
tc_id = tc.get("id")
|
||||
if not tc_id:
|
||||
# Can't synthesize a result without an id; surface it rather than
|
||||
# silently leave a dangling call that 400s on the next turn.
|
||||
print("[history] warning: assistant tool_call has no id; cannot repair")
|
||||
continue
|
||||
if tc_id in answered or tc_id in synthesized:
|
||||
continue
|
||||
synthesized.add(tc_id)
|
||||
name = (tc.get("function") or {}).get("name") or "the requested action"
|
||||
repaired.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc_id,
|
||||
"content": f"{name} was not completed — the traveler continued without confirming.",
|
||||
}
|
||||
)
|
||||
return repaired
|
||||
|
||||
|
||||
async def _replace_history_with_client(_agent, _thread_id, input_messages):
|
||||
"""Replace the checkpoint's messages with the client's full history each turn,
|
||||
repairing any dangling tool_call (e.g. an abandoned book_flight HITL) first."""
|
||||
if not input_messages:
|
||||
return input_messages
|
||||
return [RemoveMessage(id=REMOVE_ALL_MESSAGES), *_repair_dangling_tool_calls(input_messages)]
|
||||
|
||||
|
||||
_lg_runner.filter_only_new_messages = _replace_history_with_client
|
||||
|
||||
# ── Oracle checkpointer injection (Plan §3, Option A) ─────────────────────────
|
||||
# ag_ui_agentspec's load_agent_spec hardcodes checkpointer=MemorySaver(); we
|
||||
# replace it so the LangGraph graph is compiled with our flag-gated checkpointer
|
||||
# (AsyncOracleSaver when LANGGRAPH_CHECKPOINTER=oracle, else MemorySaver). The
|
||||
# underlying pyagentspec AgentSpecLoader already accepts a checkpointer; only the
|
||||
# convenience wrapper needed patching. Drop this once the upstream adapter takes a
|
||||
# checkpointer param (Plan §3, Option B). AgentSpecAgent.__init__ resolves the name
|
||||
# from ag_ui_agentspec.agent, so we rebind both module namespaces.
|
||||
import ag_ui_agentspec.agent as _agent_mod # noqa: E402
|
||||
import ag_ui_agentspec.agentspecloader as _asl_mod # noqa: E402
|
||||
from pyagentspec.adapters.langgraph import AgentSpecLoader as _LGLoader # noqa: E402
|
||||
|
||||
from .checkpointer import resolve_checkpointer, init_checkpointer, close_checkpointer # noqa: E402
|
||||
|
||||
_orig_load_agent_spec = _agent_mod.load_agent_spec
|
||||
|
||||
|
||||
def _load_agent_spec_with_checkpointer(
|
||||
runtime, agent_spec_json, tool_registry=None, components_registry=None
|
||||
):
|
||||
if runtime != "langgraph":
|
||||
return _orig_load_agent_spec(
|
||||
runtime, agent_spec_json, tool_registry, components_registry
|
||||
)
|
||||
return _LGLoader(
|
||||
tool_registry=tool_registry, checkpointer=resolve_checkpointer()
|
||||
).load_json(agent_spec_json, components_registry)
|
||||
|
||||
|
||||
_agent_mod.load_agent_spec = _load_agent_spec_with_checkpointer
|
||||
_asl_mod.load_agent_spec = _load_agent_spec_with_checkpointer
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app: FastAPI):
|
||||
# Build the Oracle checkpointer (if LANGGRAPH_CHECKPOINTER=oracle) before the
|
||||
# lazy agent build so resolve_checkpointer() sees an initialised saver. No-op
|
||||
# under the default `memory` flag.
|
||||
await init_checkpointer()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Drain in-flight background persists on shutdown so a graceful stop doesn't
|
||||
# drop the last turn's memory write. Loop rather than a single gather: a
|
||||
# request finishing during the drain can add a task after the snapshot, so
|
||||
# re-check until the set is empty. Persists are serialized (one at a time).
|
||||
while _PERSIST_TASKS:
|
||||
await asyncio.gather(*list(_PERSIST_TASKS), return_exceptions=True)
|
||||
await close_checkpointer()
|
||||
|
||||
|
||||
app = FastAPI(title="Oracle Concierge Agent", lifespan=_lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _get_agentspec_agent() -> AgentSpecAgent:
|
||||
"""Build the agent once, on first request. Construction eagerly resolves the
|
||||
LLM (ChatOpenAI), which needs OPENAI_API_KEY, so we defer it out of import."""
|
||||
return AgentSpecAgent(
|
||||
build_agent_json(), runtime="langgraph", tool_registry=TOOL_REGISTRY
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _last_user_message(messages: list) -> str:
|
||||
for message in reversed(messages):
|
||||
if getattr(message, "role", None) == "user":
|
||||
return getattr(message, "content", "") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def _clean_assistant_text(parts: list[str]) -> str:
|
||||
"""Assemble the streamed assistant deltas into the text we persist to memory.
|
||||
|
||||
The agentspec exporter HTML-escapes every TEXT_MESSAGE_CHUNK delta for safe
|
||||
transport to the browser (agentspec_tracing_exporter._escape_html: & < > ->
|
||||
& < >). We must reverse that before persisting, or Oracle Agent
|
||||
Memory stores corrupted facts like "fares < $700" and recall/extraction
|
||||
operate on the mangled text. Join first, then unescape, so an entity split
|
||||
across two delta boundaries (e.g. "&l" + "t;") is still decoded correctly.
|
||||
The streamed copy yielded to the client is untouched — only the persisted
|
||||
copy is unescaped here.
|
||||
"""
|
||||
return html.unescape("".join(parts))
|
||||
|
||||
|
||||
# Background persistence tasks are tracked here so the event loop keeps a strong
|
||||
# reference until each finishes — a bare fire-and-forget task can be garbage
|
||||
# collected mid-flight (see the asyncio.create_task docs).
|
||||
_PERSIST_TASKS: set[asyncio.Task] = set()
|
||||
# Serialize background persists: only one extraction + reconciliation runs at a
|
||||
# time. The old await made the client wait on stream-close before sending the
|
||||
# next turn, which serialized persists for free; now that the stream closes at
|
||||
# RUN_FINISHED, overlapping turns could otherwise run reconcile's read-modify-
|
||||
# write concurrently (racing on which durable fact "wins") and exhaust the small
|
||||
# Oracle connection pool. Background persists queue on this lock instead.
|
||||
_PERSIST_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
async def _persist_serialized(user_text: str, assistant_text: str) -> None:
|
||||
async with _PERSIST_LOCK:
|
||||
await asyncio.to_thread(_persist_sync, user_text, assistant_text)
|
||||
|
||||
|
||||
def _on_persist_done(task: asyncio.Task) -> None:
|
||||
"""Drop the task ref and surface any failure. The write happens off the
|
||||
request path, so a silently-dropped task exception would make a lost write
|
||||
invisible. _persist_sync swallows its own DB/LLM errors; this catches
|
||||
cancellation (loop shutdown) and scheduling failures that would vanish."""
|
||||
_PERSIST_TASKS.discard(task)
|
||||
if task.cancelled():
|
||||
print("[persist] warning: background persist cancelled before completing")
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
print(f"[persist] warning: background persist task failed ({exc!r})")
|
||||
|
||||
|
||||
def _spawn_persist(user_text: str, assistant_text: str) -> None:
|
||||
"""Run persistence off the request's critical path.
|
||||
|
||||
_persist_sync makes two LLM calls (memory extraction + reconciliation) plus
|
||||
DB writes — ~2-13s in practice. Awaiting it in the SSE generator's finally
|
||||
held the HTTP stream open that whole time *after* RUN_FINISHED, so the client
|
||||
(which ends its run/loading state on stream-close, not on RUN_FINISHED) showed
|
||||
a multi-second lag once the reply had already finished. Spawning it as a
|
||||
tracked, serialized background task lets the stream close at RUN_FINISHED; the
|
||||
write still lands a few seconds later, well before a human starts the next turn.
|
||||
"""
|
||||
task = asyncio.create_task(_persist_serialized(user_text, assistant_text))
|
||||
_PERSIST_TASKS.add(task)
|
||||
task.add_done_callback(_on_persist_done)
|
||||
|
||||
|
||||
def _persist_sync(user_text: str, assistant_text: str) -> None:
|
||||
"""Persist the exchange for memory extraction, then supersede stale facts.
|
||||
|
||||
Both turns are stored: extraction reliably distills durable facts from a full
|
||||
user+assistant exchange, whereas a lone user turn often yields only a raw
|
||||
"message" record and no extracted fact. The agent's echoes land as "message"
|
||||
records too, but recall_memory filters those out (see tools.DURABLE_RECORD_TYPES),
|
||||
so they never re-assert a stale preference. Reconciliation then deletes
|
||||
outdated/duplicate *durable* facts so an updated preference wins on recall.
|
||||
"""
|
||||
exchange: list[dict[str, str]] = []
|
||||
if user_text:
|
||||
exchange.append({"role": "user", "content": user_text})
|
||||
if assistant_text:
|
||||
exchange.append({"role": "assistant", "content": assistant_text})
|
||||
if not exchange:
|
||||
return
|
||||
try:
|
||||
memory = get_memory()
|
||||
thread = memory.create_thread(user_id=DEMO_USER_ID)
|
||||
thread.add_messages(exchange) # triggers automatic memory extraction
|
||||
# Delete outdated/duplicate durable facts so the newest preference wins on recall.
|
||||
reconcile_durable_memories(DEMO_USER_ID)
|
||||
except Exception as exc: # persistence is best-effort; a DB blip must not 500 the run
|
||||
# Mirror recall_memory's graceful degradation: the chat reply already streamed
|
||||
# successfully, so swallow + log rather than raising out of the SSE generator's
|
||||
# finally (which surfaced as "Exception in ASGI application" while the DB was down).
|
||||
print(f"[persist] warning: memory persist failed, degrading gracefully ({exc})")
|
||||
|
||||
|
||||
@app.post("/run")
|
||||
async def run_endpoint(input_data: RunAgentInput, request: Request):
|
||||
"""Stream the Agent Spec run over AG-UI, then persist the turn in the background.
|
||||
|
||||
The event_generator mirrors the adapter's endpoint.py: a per-request queue is
|
||||
set into EVENT_QUEUE, the run is spawned as a task, and events are drained to
|
||||
SSE. We additionally collect the assistant's text deltas and, once the stream
|
||||
closes, spawn persistence as a background task (off the critical path).
|
||||
"""
|
||||
encoder = EventEncoder(accept=request.headers.get("accept"))
|
||||
user_text = _last_user_message(input_data.messages)
|
||||
|
||||
async def event_generator():
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
token = EVENT_QUEUE.set(queue)
|
||||
assistant_parts: list[str] = []
|
||||
|
||||
async def run_and_close():
|
||||
try:
|
||||
await _get_agentspec_agent().run(input_data)
|
||||
except Exception as exc: # surface failures to the client
|
||||
queue.put_nowait(RunErrorEvent(message=repr(exc)))
|
||||
finally:
|
||||
queue.put_nowait(None)
|
||||
|
||||
try:
|
||||
asyncio.create_task(run_and_close())
|
||||
while True:
|
||||
item = await queue.get()
|
||||
if item is None:
|
||||
break
|
||||
if item.type in (EventType.RUN_STARTED, EventType.RUN_FINISHED):
|
||||
item.thread_id = input_data.thread_id
|
||||
item.run_id = input_data.run_id
|
||||
if item.type == EventType.TEXT_MESSAGE_CHUNK:
|
||||
assistant_parts.append(getattr(item, "delta", "") or "")
|
||||
yield encoder.encode(item)
|
||||
except Exception as exc:
|
||||
yield encoder.encode(RunErrorEvent(message=str(exc)))
|
||||
finally:
|
||||
EVENT_QUEUE.reset(token)
|
||||
# Persist off the critical path so the SSE stream closes at RUN_FINISHED
|
||||
# instead of blocking on memory extraction + reconciliation. The write
|
||||
# still lands shortly after, so the next session can recall it.
|
||||
_spawn_persist(user_text, _clean_assistant_text(assistant_parts))
|
||||
|
||||
return StreamingResponse(event_generator(), media_type=encoder.get_content_type())
|
||||
@@ -0,0 +1,157 @@
|
||||
"""ServerTool and ClientTool implementations + their Agent Spec declarations and registry.
|
||||
|
||||
- recall_memory: durable, cross-session recall from Oracle Agent Memory (ServerTool).
|
||||
- search_flights: mock flight-search tool by destination (ServerTool, canned options).
|
||||
- book_flight: client-side booking tool — the UI handles confirmation (ClientTool, HITL).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from oracleagentmemory.apis.searchscope import SearchScope
|
||||
from pyagentspec.property import Property
|
||||
from pyagentspec.tools import ServerTool, ClientTool
|
||||
|
||||
from .memory import get_memory
|
||||
|
||||
# The adapter drops forwarded_props, so a single-user cookbook defaults here.
|
||||
# To scope per real user, set this from a ContextVar populated by a FastAPI
|
||||
# dependency (e.g. an X-User-Id header). See server.py.
|
||||
DEMO_USER_ID = "demo-user"
|
||||
|
||||
# ── Mock flight inventory (keeps the tool runnable without a travel API) ──────
|
||||
_FLIGHTS = [
|
||||
{
|
||||
"id": "AMS-001",
|
||||
"airline": "KLM",
|
||||
"flight_no": "KL606",
|
||||
"origin": "SFO",
|
||||
"destination": "Amsterdam (AMS)",
|
||||
"depart": "2026-07-12T13:25",
|
||||
"arrive": "2026-07-13T09:10",
|
||||
"duration": "10h 45m",
|
||||
"stops": 0,
|
||||
"cabin": "Economy",
|
||||
"price_usd": 740,
|
||||
"notes": "Nonstop · aisle seats available · vegetarian meal on request",
|
||||
},
|
||||
{
|
||||
"id": "AMS-002",
|
||||
"airline": "United",
|
||||
"flight_no": "UA950",
|
||||
"origin": "SFO",
|
||||
"destination": "Amsterdam (AMS)",
|
||||
"depart": "2026-07-12T15:40",
|
||||
"arrive": "2026-07-13T14:05",
|
||||
"duration": "13h 25m",
|
||||
"stops": 1,
|
||||
"cabin": "Economy",
|
||||
"price_usd": 612,
|
||||
"notes": "1 stop (EWR) · vegetarian meal on request",
|
||||
},
|
||||
{
|
||||
"id": "LIS-010",
|
||||
"airline": "TAP Air Portugal",
|
||||
"flight_no": "TP238",
|
||||
"origin": "SFO",
|
||||
"destination": "Lisbon (LIS)",
|
||||
"depart": "2026-07-12T16:10",
|
||||
"arrive": "2026-07-13T13:30",
|
||||
"duration": "12h 20m",
|
||||
"stops": 1,
|
||||
"cabin": "Economy",
|
||||
"price_usd": 690,
|
||||
"notes": "1 stop (LIS) · ocean-view layover",
|
||||
},
|
||||
{
|
||||
"id": "TYO-021",
|
||||
"airline": "ANA",
|
||||
"flight_no": "NH7",
|
||||
"origin": "SFO",
|
||||
"destination": "Tokyo (HND)",
|
||||
"depart": "2026-07-12T11:00",
|
||||
"arrive": "2026-07-13T15:35",
|
||||
"duration": "11h 35m",
|
||||
"stops": 0,
|
||||
"cabin": "Economy",
|
||||
"price_usd": 1480,
|
||||
"notes": "Nonstop · aisle seats available · JR pass add-on",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Durable record types worth recalling. Excludes "message" — the raw chat turns
|
||||
# (including the agent's own replies like "You usually fly out of SFO…"), which
|
||||
# otherwise dominate recall and re-assert stale preferences.
|
||||
DURABLE_RECORD_TYPES = ["preference", "memory", "fact", "guideline"]
|
||||
|
||||
|
||||
def _recall_sync(query: str) -> str:
|
||||
try:
|
||||
memory = get_memory()
|
||||
results = memory.search(
|
||||
query=query,
|
||||
scope=SearchScope(user_id=DEMO_USER_ID),
|
||||
record_types=DURABLE_RECORD_TYPES,
|
||||
max_results=20,
|
||||
)
|
||||
contents: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for r in results:
|
||||
c = (r.content or "").strip()
|
||||
if not c or c.lower() in seen:
|
||||
continue
|
||||
seen.add(c.lower())
|
||||
contents.append(c)
|
||||
if len(contents) >= 6:
|
||||
break
|
||||
return "\n".join(f"- {c}" for c in contents) if contents else "No relevant memories."
|
||||
except Exception as exc: # memory is an enhancement, not a hard dependency
|
||||
print(f"[recall_memory] warning: memory search failed, degrading gracefully ({exc})")
|
||||
return "No relevant memories."
|
||||
|
||||
|
||||
async def recall_memory(query: str) -> str:
|
||||
"""Recall the traveler's durable preferences relevant to `query`."""
|
||||
return await asyncio.to_thread(_recall_sync, query)
|
||||
|
||||
|
||||
async def search_flights(destination: str) -> str:
|
||||
"""Return mock flight options matching `destination` (or all, if no match)."""
|
||||
matches = [t for t in _FLIGHTS if destination.lower() in t["destination"].lower()]
|
||||
return json.dumps(matches or _FLIGHTS)
|
||||
|
||||
|
||||
def _str_prop(title: str, description: str) -> Property:
|
||||
return Property(title=title, json_schema={"title": title, "type": "string", "description": description})
|
||||
|
||||
|
||||
recall_memory_tool = ServerTool(
|
||||
name="recall_memory",
|
||||
description="Recall the traveler's durable saved preferences relevant to a query.",
|
||||
inputs=[_str_prop("query", "What to recall, e.g. 'dietary needs' or 'seat preference'.")],
|
||||
outputs=[_str_prop("memories", "Relevant recalled preferences, newline-separated.")],
|
||||
)
|
||||
|
||||
search_flights_tool = ServerTool(
|
||||
name="search_flights",
|
||||
description="Search available flight options by destination.",
|
||||
inputs=[_str_prop("destination", "Destination city to search for, e.g. 'Amsterdam'.")],
|
||||
outputs=[_str_prop("results", "JSON array of matching flight options.")],
|
||||
)
|
||||
|
||||
book_flight_tool = ClientTool(
|
||||
name="book_flight",
|
||||
description="Book the chosen flight by its id. The traveler confirms in the UI before it is finalized.",
|
||||
inputs=[_str_prop("flight_id", "The id of the flight to book, e.g. 'AMS-001'.")],
|
||||
outputs=[_str_prop("confirmation", "Human-readable booking confirmation.")],
|
||||
)
|
||||
|
||||
TOOLS = [recall_memory_tool, search_flights_tool, book_flight_tool]
|
||||
# book_flight is client-executed (ClientTool / HITL) — it must NOT appear here.
|
||||
TOOL_REGISTRY = {
|
||||
"recall_memory": recall_memory,
|
||||
"search_flights": search_flights,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[project]
|
||||
name = "concierge-agent"
|
||||
version = "0.1.0"
|
||||
description = "Oracle Agent Spec × Agent Memory × CopilotKit — travel concierge agent"
|
||||
# oracleagentmemory 26.4.0 ships a cp312-only wheel, so pin to 3.12.
|
||||
requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
# Agent Spec → AG-UI adapter (not on PyPI; installed from the ag-ui monorepo).
|
||||
# The [langgraph] extra transitively brings langgraph + langchain + pyagentspec.
|
||||
"ag-ui-agent-spec[langgraph] @ git+https://github.com/ag-ui-protocol/ag-ui.git#subdirectory=integrations/agent-spec/python",
|
||||
# Persistent agent memory on Oracle AI Database, and the DB driver it talks to.
|
||||
"oracleagentmemory==26.4.0",
|
||||
"oracledb>=2.2.0",
|
||||
"langgraph-oracledb>=1.0.1",
|
||||
# HTTP server + env loading.
|
||||
"uvicorn[standard]>=0.30",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
|
||||
# Runnable app, not a distributable library.
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=9.1.1", "pytest-asyncio>=1.4.0"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://railway.com/railway.schema.json",
|
||||
"build": { "builder": "DOCKERFILE", "dockerfilePath": "Dockerfile" },
|
||||
"deploy": { "healthcheckPath": "/health", "restartPolicyType": "ON_FAILURE" }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Wipe a user's stored memories for a clean demo slate.
|
||||
|
||||
Usage (from the agent/ dir):
|
||||
uv run python scripts/reset_memory.py # resets demo-user
|
||||
uv run python scripts/reset_memory.py some-user
|
||||
|
||||
`oracleagentmemory` keys its records by USER_ID, so a scoped DELETE across its
|
||||
tables is a clean, deterministic purge (the VECTOR$ index on RECORD_CHUNKS
|
||||
self-maintains on DML) — more reliable than deleting search hits one by one, which
|
||||
can miss records the ranked search never returns. The agent re-creates preferences
|
||||
on the next conversation. Safe to run repeatedly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import oracledb
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Children first; the VECTOR$ index on RECORD_CHUNKS auto-maintains on delete.
|
||||
_TABLES = ("RECORD_CHUNKS", "MEMORY", "MESSAGE", "THREAD")
|
||||
|
||||
|
||||
def reset(user_id: str) -> dict[str, object]:
|
||||
conn = oracledb.connect(
|
||||
user=os.environ["ORACLE_DB_USER"],
|
||||
password=os.environ["ORACLE_DB_PASSWORD"],
|
||||
dsn=os.environ["ORACLE_DB_DSN"],
|
||||
)
|
||||
cur = conn.cursor()
|
||||
deleted: dict[str, object] = {}
|
||||
for table in _TABLES:
|
||||
try:
|
||||
cur.execute(f'DELETE FROM "{table}" WHERE USER_ID = :1', [user_id])
|
||||
deleted[table] = cur.rowcount
|
||||
except Exception as exc: # ORA-00942 on a fresh DB (table not created) is fine
|
||||
deleted[table] = f"skip ({type(exc).__name__})"
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return deleted
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else "demo-user"
|
||||
result = reset(target)
|
||||
print(f"Reset complete for user {target!r}: {result}")
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Oracle checkpointer durability round-trip test.
|
||||
|
||||
Verifies that LangGraph state written through AsyncOracleSaver actually
|
||||
persists in Oracle so that a *fresh* saver (simulating a process restart)
|
||||
can read the same thread back from the DB.
|
||||
|
||||
Skipped unless ``LANGGRAPH_CHECKPOINTER=oracle`` is set in the environment —
|
||||
requires a live Oracle DB with the env vars ``ORACLE_DB_USER``,
|
||||
``ORACLE_DB_PASSWORD``, and ``ORACLE_DB_DSN`` present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Module-level skip: entire file is skipped unless Oracle is selected.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.getenv("LANGGRAPH_CHECKPOINTER", "memory").lower() != "oracle",
|
||||
reason="requires LANGGRAPH_CHECKPOINTER=oracle + a live Oracle DB",
|
||||
)
|
||||
|
||||
|
||||
async def test_checkpoint_survives_fresh_saver() -> None:
|
||||
"""State written through saver1 must be readable through saver2 (same DB).
|
||||
|
||||
This proves durability: the checkpoint lives in Oracle, not in-process RAM.
|
||||
"""
|
||||
import oracledb
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
from langgraph_oracledb.checkpoint.oracle import AsyncOracleSaver
|
||||
|
||||
# Load .env so credentials are available when running from the agent dir.
|
||||
load_dotenv()
|
||||
|
||||
def _require(name: str) -> str:
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise RuntimeError(
|
||||
f"Missing required environment variable {name!r}. "
|
||||
"Copy agent/.env.example to agent/.env and fill it in."
|
||||
)
|
||||
return value
|
||||
|
||||
user = _require("ORACLE_DB_USER")
|
||||
password = _require("ORACLE_DB_PASSWORD")
|
||||
dsn = _require("ORACLE_DB_DSN")
|
||||
|
||||
# Use a unique thread_id so parallel/repeated test runs don't clash.
|
||||
thread_id = f"verify-{int(time.time())}"
|
||||
config = {"configurable": {"thread_id": thread_id}}
|
||||
|
||||
# ── Trivial graph definition (reused for both compilations) ──────────────
|
||||
def _build_graph(checkpointer):
|
||||
def probe_node(state: MessagesState):
|
||||
return {"messages": [AIMessage(content="durability-probe")]}
|
||||
|
||||
sg = StateGraph(MessagesState)
|
||||
sg.add_node("probe", probe_node)
|
||||
sg.add_edge(START, "probe")
|
||||
sg.add_edge("probe", END)
|
||||
return sg.compile(checkpointer=checkpointer)
|
||||
|
||||
pool1 = None
|
||||
pool2 = None
|
||||
try:
|
||||
# ── Phase 1: write a checkpoint via saver1 ───────────────────────────
|
||||
pool1 = oracledb.create_pool_async(
|
||||
user=user,
|
||||
password=password,
|
||||
dsn=dsn,
|
||||
min=1,
|
||||
max=4,
|
||||
increment=1,
|
||||
)
|
||||
saver1 = AsyncOracleSaver(pool1)
|
||||
await saver1.setup()
|
||||
|
||||
graph1 = _build_graph(saver1)
|
||||
async for _ in graph1.astream(
|
||||
{"messages": [HumanMessage(content="hi")]}, config
|
||||
):
|
||||
pass
|
||||
|
||||
# ── Phase 2: read it back via saver2 (fresh saver, same DB) ─────────
|
||||
pool2 = oracledb.create_pool_async(
|
||||
user=user,
|
||||
password=password,
|
||||
dsn=dsn,
|
||||
min=1,
|
||||
max=4,
|
||||
increment=1,
|
||||
)
|
||||
saver2 = AsyncOracleSaver(pool2)
|
||||
# No setup() needed to read; tables already exist.
|
||||
|
||||
graph2 = _build_graph(saver2)
|
||||
state = await graph2.aget_state(config)
|
||||
|
||||
# Collect all message contents for assertion.
|
||||
messages = state.values.get("messages", [])
|
||||
contents = [getattr(m, "content", "") for m in messages]
|
||||
|
||||
assert any(
|
||||
"durability-probe" in c for c in contents
|
||||
), f"Expected 'durability-probe' in messages, got: {contents}"
|
||||
|
||||
assert any(
|
||||
"hi" in c for c in contents
|
||||
), f"Expected HumanMessage 'hi' in messages, got: {contents}"
|
||||
|
||||
finally:
|
||||
if pool1 is not None:
|
||||
await pool1.close()
|
||||
if pool2 is not None:
|
||||
await pool2.close()
|
||||
+1619
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
# Custom Oracle AI Database image for Railway (Option B — self-host).
|
||||
#
|
||||
# It is the freely-pullable Oracle Database Free image with the `cookbook` user
|
||||
# baked in, so the database comes up ready with no manual setup step.
|
||||
#
|
||||
# Build + push to a PRIVATE registry ONLY — re-publishing Oracle's image
|
||||
# publicly violates the Oracle license. See build-and-push.sh.
|
||||
#
|
||||
# :latest-lite is the smaller Free variant (faster pulls / less disk on Railway)
|
||||
# and still includes AI Vector Search. Switch to :latest if you hit a missing
|
||||
# feature.
|
||||
FROM container-registry.oracle.com/database/free:latest-lite
|
||||
|
||||
# Admin (SYS / SYSTEM / PDBADMIN) password — matches the local docker-compose default.
|
||||
ENV ORACLE_PWD=cookbook_admin_pw
|
||||
|
||||
# Startup scripts (run on every container start, alphabetical order). We use the
|
||||
# *startup* hook rather than the *setup* hook: the Free image does NOT reliably
|
||||
# execute setup/ scripts (see the note in db/init/01-create-user.sql), and our SQL
|
||||
# is idempotent, so running it each boot is safe and robust. Baking it in (vs a
|
||||
# volume mount) also avoids the mount-timing race the local compose hit.
|
||||
#
|
||||
# 00 registers FREEPDB1 with the TCP listener on the container's IPv4 address so the
|
||||
# agent can reach it over Railway's dual-stack private network (Railway fix — see
|
||||
# the file header). A shell script (not SQL) because it must read the IPv4 from
|
||||
# `hostname -I`; UTL_INADDR only yields the IPv6 address. No exec bit / chmod: the
|
||||
# image runs as a non-root user (chmod during build is denied), and the startup
|
||||
# hook *sources* non-executable .sh files — the script is written to be source-safe.
|
||||
# 01 creates the `cookbook` application user (idempotent).
|
||||
COPY init/00-register-listener.sh /opt/oracle/scripts/startup/00-register-listener.sh
|
||||
COPY init/01-create-user.sql /opt/oracle/scripts/startup/01-create-user.sql
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the custom Oracle DB image and push it to a PRIVATE registry for Railway.
|
||||
#
|
||||
# One-time prerequisites (you):
|
||||
# 1. Accept the image license at https://container-registry.oracle.com
|
||||
# (Database -> free), signed in with your Oracle SSO account.
|
||||
# 2. docker login container-registry.oracle.com # Oracle SSO
|
||||
# 3. docker login ghcr.io -u <github-username> # GitHub PAT w/ write:packages
|
||||
#
|
||||
# Keep the target repo PRIVATE — re-publishing Oracle's image violates the license.
|
||||
set -euo pipefail
|
||||
|
||||
# Set IMAGE, or replace OWNER with your own PRIVATE GHCR namespace before running.
|
||||
IMAGE="${IMAGE:-ghcr.io/OWNER/oracle-agent-memory-db:latest}"
|
||||
|
||||
cd "$(dirname "$0")" # db/ — build context includes init/
|
||||
|
||||
echo "Building $IMAGE ..."
|
||||
docker build -t "$IMAGE" .
|
||||
|
||||
echo "Pushing $IMAGE ..."
|
||||
docker push "$IMAGE"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Done: $IMAGE (keep this repo PRIVATE — Oracle license).
|
||||
|
||||
In Railway, create the 'oracle-db' service from it:
|
||||
- Source: Docker image -> $IMAGE (add GHCR pull credentials if private)
|
||||
- Volume: mount at /opt/oracle/oradata
|
||||
- Resources: >= 2 GB RAM
|
||||
- Wait for "DATABASE IS READY TO USE" in the logs before deploying the agent.
|
||||
EOF
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Railway remote-access fix (ORA-12514 / DPY-6001 "FREEPDB1 not registered").
|
||||
#
|
||||
# Railway's private network is dual-stack. The agent connects to the database over
|
||||
# IPv4 (10.x), but the DB's primary address -- what UTL_INADDR.GET_HOST_ADDRESS
|
||||
# returns -- is IPv6 (fd12:...). Registering the listener service on the IPv6 address
|
||||
# therefore leaves the IPv4-connecting agent with ORA-12514. This script pins
|
||||
# LOCAL_LISTENER to the container's own IPv4 address so PMON publishes both FREE (CDB)
|
||||
# and FREEPDB1 (PDB) to the TCP listener on the exact endpoint the agent reaches.
|
||||
#
|
||||
# Runs on every container boot (startup hook). SCOPE=MEMORY -- re-applied each boot.
|
||||
# The echoed IP + sqlplus output let the deploy logs confirm the fix applied, and on
|
||||
# which address, without needing shell access to the running container.
|
||||
#
|
||||
# Written to be safe whether the startup runner executes or sources it: no `set -e`
|
||||
# and no `exit` (which would abort the runner / skip 01-create-user.sql).
|
||||
|
||||
IP4="$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -vE '^127\.' | head -1)"
|
||||
|
||||
if [ -z "$IP4" ]; then
|
||||
echo "[register-listener] no non-loopback IPv4 found (hostname -I: $(hostname -I 2>/dev/null)); leaving LOCAL_LISTENER at default"
|
||||
else
|
||||
echo "[register-listener] registering services on IPv4 ${IP4}:1521"
|
||||
SQLPLUS="${ORACLE_HOME:+${ORACLE_HOME}/bin/}sqlplus"
|
||||
"$SQLPLUS" -s "/ as sysdba" <<SQL
|
||||
WHENEVER SQLERROR CONTINUE
|
||||
ALTER SESSION SET CONTAINER = CDB\$ROOT;
|
||||
ALTER SYSTEM SET LOCAL_LISTENER='(ADDRESS=(PROTOCOL=TCP)(HOST=${IP4})(PORT=1521))' SCOPE=MEMORY;
|
||||
ALTER SYSTEM REGISTER;
|
||||
EXIT
|
||||
SQL
|
||||
echo "[register-listener] done (IPv4 ${IP4})"
|
||||
fi
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Creates the `cookbook` application user inside the FREEPDB1 pluggable database
|
||||
-- with the privileges oracleagentmemory needs (tables + AI Vector Search), and a
|
||||
-- dedicated ASSM tablespace as its DEFAULT.
|
||||
--
|
||||
-- WHY THE ASSM TABLESPACE: oracleagentmemory creates tables with native JSON columns
|
||||
-- and a VECTOR column + HNSW vector index. Those segments are SecureFile LOB/BLOBs,
|
||||
-- which Oracle forbids in a Manual Segment Space Management (MSSM) tablespace
|
||||
-- (ORA-43853). On the Free `:latest-lite` image FREEPDB1 has NO USERS tablespace and
|
||||
-- its default permanent tablespace is SYSTEM (MSSM), so a user with no DEFAULT
|
||||
-- TABLESPACE lands on SYSTEM and the first JSON table fails. We therefore create an
|
||||
-- ASSM tablespace (cookbook_ts) and make it the user's default.
|
||||
--
|
||||
-- Idempotent — safe to run on every container boot (startup hook). Self-heals ONLY
|
||||
-- the genuinely broken case: a `cookbook` user stranded on the SYSTEM tablespace
|
||||
-- (MSSM), which is FREEPDB1's default permanent tablespace on :latest-lite and is what
|
||||
-- raises ORA-43853. Such a user is dropped (CASCADE) and recreated on cookbook_ts.
|
||||
-- DROP USER CASCADE is the correct self-heal because Oracle DDL auto-commits: a prior
|
||||
-- failed run can leave non-JSON tables (schema_meta, actor_profile) stranded in SYSTEM
|
||||
-- with empty metadata, and oracleagentmemory's 'create_if_necessary' policy will NOT
|
||||
-- recover from that — it raises a metadata-validation error instead of recreating into
|
||||
-- the new tablespace. CASCADE removes those stranded objects regardless of tablespace.
|
||||
--
|
||||
-- A user already on a working ASSM tablespace (e.g. USERS on the local :latest image)
|
||||
-- is LEFT UNTOUCHED, so re-running stays non-destructive on the documented local flow.
|
||||
-- The self-heal drop assumes a fresh boot with no live `cookbook` session (true for the
|
||||
-- ephemeral, no-volume demo: the DB is recreated each restart). Using a persisted volume
|
||||
-- would need session-disconnect handling before the drop (the agent reconnects as soon
|
||||
-- as the listener is up) — out of scope while the demo runs volume-less.
|
||||
ALTER SESSION SET CONTAINER = FREEPDB1;
|
||||
|
||||
SET SERVEROUTPUT ON
|
||||
DECLARE
|
||||
ts_count INTEGER;
|
||||
user_count INTEGER;
|
||||
default_ts VARCHAR2(128);
|
||||
BEGIN
|
||||
-- 1) Dedicated ASSM tablespace (guarded: CREATE TABLESPACE is NOT idempotent and
|
||||
-- raises ORA-01543 if it already exists; REUSE only protects the datafile, not
|
||||
-- the tablespace metadata). The datafile path is the verified PDB datafile dir
|
||||
-- for the Free image (ORACLE_SID/db_name = FREE; OMF is off so an explicit path
|
||||
-- is required). REUSE re-adopts an orphaned datafile left on the persisted volume.
|
||||
SELECT COUNT(*) INTO ts_count
|
||||
FROM dba_tablespaces
|
||||
WHERE tablespace_name = 'COOKBOOK_TS';
|
||||
IF ts_count = 0 THEN
|
||||
EXECUTE IMMEDIATE
|
||||
'CREATE TABLESPACE cookbook_ts ' ||
|
||||
'DATAFILE ''/opt/oracle/oradata/FREE/FREEPDB1/cookbook_ts01.dbf'' ' ||
|
||||
'SIZE 256M REUSE AUTOEXTEND ON NEXT 64M MAXSIZE 2G ' ||
|
||||
'EXTENT MANAGEMENT LOCAL SEGMENT SPACE MANAGEMENT AUTO';
|
||||
DBMS_OUTPUT.PUT_LINE('cookbook_ts tablespace created (ASSM)');
|
||||
ELSE
|
||||
DBMS_OUTPUT.PUT_LINE('cookbook_ts tablespace already exists - skipping');
|
||||
END IF;
|
||||
|
||||
-- 2) Self-heal ONLY a user stranded on SYSTEM (MSSM) — the sole default that causes
|
||||
-- ORA-43853 (only FREEPDB1's default on :latest-lite). Drop + recreate it on the
|
||||
-- ASSM tablespace. A user already on a working ASSM tablespace (e.g. USERS on the
|
||||
-- local :latest image, or cookbook_ts itself) is left untouched, so re-running is
|
||||
-- non-destructive there. dba_users.default_tablespace is stored uppercase.
|
||||
SELECT COUNT(*) INTO user_count
|
||||
FROM dba_users WHERE username = 'COOKBOOK';
|
||||
|
||||
IF user_count > 0 THEN
|
||||
SELECT default_tablespace INTO default_ts
|
||||
FROM dba_users WHERE username = 'COOKBOOK';
|
||||
IF default_ts = 'SYSTEM' THEN
|
||||
DBMS_OUTPUT.PUT_LINE('cookbook is on SYSTEM (MSSM) - dropping to self-heal onto cookbook_ts');
|
||||
EXECUTE IMMEDIATE 'DROP USER cookbook CASCADE';
|
||||
user_count := 0;
|
||||
ELSE
|
||||
DBMS_OUTPUT.PUT_LINE('cookbook user default tablespace is ' || default_ts ||
|
||||
' (ASSM) - leaving as-is');
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF user_count = 0 THEN
|
||||
EXECUTE IMMEDIATE
|
||||
'CREATE USER cookbook IDENTIFIED BY "cookbook_pw" ' ||
|
||||
'DEFAULT TABLESPACE cookbook_ts TEMPORARY TABLESPACE temp';
|
||||
EXECUTE IMMEDIATE 'GRANT DB_DEVELOPER_ROLE TO cookbook';
|
||||
-- GRANT UNLIMITED TABLESPACE covers quota on every tablespace (incl. cookbook_ts),
|
||||
-- so no separate QUOTA clause is needed; one privilege, no redundancy.
|
||||
EXECUTE IMMEDIATE 'GRANT UNLIMITED TABLESPACE TO cookbook';
|
||||
DBMS_OUTPUT.PUT_LINE('cookbook user created (DEFAULT TABLESPACE cookbook_ts)');
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Create the `cookbook` database user (idempotent).
|
||||
#
|
||||
# The Oracle Database Free image does not reliably auto-run scripts mounted into
|
||||
# /opt/oracle/scripts/setup, so we run the init SQL explicitly against the running
|
||||
# container. Run this once after `docker compose up -d` reports the DB ready
|
||||
# ("DATABASE IS READY TO USE"). Safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="${ORACLE_CONTAINER:-oracle-cookbook-db}"
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||
echo "Error: container '$CONTAINER' is not running. Start it with: docker compose up -d" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Ensuring the 'cookbook' user exists in FREEPDB1..."
|
||||
docker exec "$CONTAINER" bash -lc \
|
||||
'echo exit | "$ORACLE_HOME"/bin/sqlplus -s "/ as sysdba" @/opt/oracle/scripts/setup/01-create-user.sql'
|
||||
echo "Done — the 'cookbook' user is ready."
|
||||
@@ -0,0 +1,36 @@
|
||||
# Local Oracle AI Database for development.
|
||||
#
|
||||
# This uses the freely-pullable Oracle Database Free image, which includes AI
|
||||
# Vector Search (what oracleagentmemory relies on). It is provided as a
|
||||
# convenience — if it doesn't match your environment, Oracle's official guide is
|
||||
# authoritative:
|
||||
# https://docs.oracle.com/en/database/oracle/agent-memory/26.4/agmea/run-locally.html
|
||||
#
|
||||
# First boot takes a few minutes while the database is created; watch progress
|
||||
# with `docker compose logs -f oracle-db` and wait for "DATABASE IS READY TO USE".
|
||||
services:
|
||||
oracle-db:
|
||||
image: container-registry.oracle.com/database/free:latest
|
||||
container_name: oracle-cookbook-db
|
||||
ports:
|
||||
- "1521:1521"
|
||||
environment:
|
||||
# Password for the SYS / SYSTEM / PDBADMIN admin accounts.
|
||||
ORACLE_PWD: cookbook_admin_pw
|
||||
volumes:
|
||||
- oracle-data:/opt/oracle/oradata
|
||||
# One-time setup scripts (create the `cookbook` user — see db/init).
|
||||
- ./db/init:/opt/oracle/scripts/setup
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"echo 'select 1 from dual;' | sqlplus -s system/cookbook_admin_pw@localhost:1521/FREEPDB1 | grep -q '^.*1'",
|
||||
]
|
||||
interval: 20s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
start_period: 180s
|
||||
|
||||
volumes:
|
||||
oracle-data:
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# Bug: Agent Spec × AG-UI adapter breaks multi-turn conversations when server tools are used
|
||||
|
||||
**Affected package:** `ag-ui-agent-spec` (the `ag_ui_agentspec` adapter, `ag-ui-protocol/ag-ui` → `integrations/agent-spec/python`), `langgraph` runtime.
|
||||
**Stack:** `pyagentspec 26.2.0.dev6`, langgraph runtime, OpenAI via `langchain-openai`; consumed by a CopilotKit V2 frontend over AG-UI. Python 3.12.
|
||||
**Severity:** High — any conversation that uses a server-side tool fails on the _next_ user turn. Blocks multi-turn agents and the human-in-the-loop (confirm-then-act) pattern.
|
||||
|
||||
## Summary
|
||||
|
||||
When an Agent Spec agent with `ServerTool`s runs on the LangGraph runtime behind `add_agentspec_fastapi_endpoint`, the **first** turn works. The tool calls emit a warning:
|
||||
|
||||
```
|
||||
AG-UI tool-call correlation miss: no ToolExecutionRequest recorded for request_id='call_…';
|
||||
using the raw request_id as a surrogate tool_call_id. The emitted tool result may be
|
||||
orphaned because the frontend never saw this id.
|
||||
```
|
||||
|
||||
On the **second** turn (any follow-up after a turn that called a tool), the LangGraph `model` node fails:
|
||||
|
||||
```
|
||||
openai.BadRequestError: Error code: 400 - {'error': {'message': "Invalid parameter:
|
||||
messages with role 'tool' must be a response to a preceeding message with 'tool_calls'.",
|
||||
'type': 'invalid_request_error', 'param': 'messages.[N].role'}}
|
||||
```
|
||||
|
||||
## Root cause (analysis)
|
||||
|
||||
Server-side tool calls are not recorded as `ToolExecutionRequest`s, so the adapter emits the tool **result** with a _surrogate_ `tool_call_id` (the raw `request_id`) that the frontend never associated with an assistant `tool_calls` entry. The conversation history that the frontend then replays on the next turn therefore contains a `role: "tool"` message with no preceding assistant message carrying the matching `tool_calls`. OpenAI rejects that message sequence (400). The first-turn `correlation miss` warning and the second-turn 400 are the same defect observed at two points.
|
||||
|
||||
## Minimal reproduction
|
||||
|
||||
1. Define an Agent Spec `Agent` with a `ServerTool` (e.g. `recall_memory(query)`), serialize, and serve it:
|
||||
`add_agentspec_fastapi_endpoint(app, AgentSpecAgent(agent_json, runtime="langgraph", tool_registry={...}), path="/run")`.
|
||||
2. Connect any AG-UI client (CopilotKit V2).
|
||||
3. **Turn 1:** send a message that makes the model call the server tool → succeeds; server logs the `correlation miss` warning.
|
||||
4. **Turn 2:** send any follow-up → the run fails with the OpenAI 400 above; the user gets no reply.
|
||||
|
||||
Observed in the Oracle × CopilotKit cookbook: turn 1 (recall + search via `search_trips`) works and is correctly personalized; replying "confirm" (turn 2) fails with the 400, so the `book_trip` (`requires_confirmation`) HITL flow can never be reached.
|
||||
|
||||
## Impact
|
||||
|
||||
- Multi-turn conversations are broken whenever a server tool is used.
|
||||
- The human-in-the-loop `requires_confirmation` flow (propose → user confirms → execute) is unreachable, because confirmation is inherently a second turn.
|
||||
|
||||
## Suggested direction
|
||||
|
||||
Record a `ToolExecutionRequest` for every server-tool invocation so the emitted tool result carries the _same_ `tool_call_id` the assistant `tool_calls` entry used (and is visible to the frontend), so the replayed history is a valid `assistant(tool_calls) → tool(result)` sequence. Alternatively, reconcile tool_call_ids when reconstructing LangGraph message history from the incoming AG-UI `messages` so orphaned `tool` messages are repaired or dropped before the model call.
|
||||
|
||||
## Workaround (implemented)
|
||||
|
||||
The cookbook now applies a server-side workaround in `agent/concierge/server.py`. The
|
||||
LangGraph runner is checkpointed per `thread_id` and, each turn, tries to append only
|
||||
the client messages whose ids aren't already in the checkpoint
|
||||
(`filter_only_new_messages`). But CopilotKit re-sends the **full** history with ids
|
||||
that never match the checkpoint's, so a second copy of the
|
||||
`assistant(tool_calls)`/`tool` block is appended and the merged history is invalid.
|
||||
|
||||
Since the client already sends the full, valid history every turn, we replace the
|
||||
adapter's incremental merge with a full-history **replace**: monkey-patch
|
||||
`filter_only_new_messages` to prepend a `RemoveMessage(REMOVE_ALL_MESSAGES)` and return
|
||||
the client's history verbatim, so `add_messages` clears the checkpoint's copy and uses
|
||||
the client's valid history. This restores multi-turn conversations **and** makes the
|
||||
`book_flight` (`requires_confirmation`) HITL flow reachable (search → pick → confirm →
|
||||
boarding pass all work). The adapter drives every turn — including HITL resume — through
|
||||
`astream({"messages": ...})`, so the replace covers that path too.
|
||||
|
||||
### Inverse case: a dangling tool-call from an abandoned HITL booking
|
||||
|
||||
The full-history replace handles the _duplicate/orphan_ direction above, but a second
|
||||
failure mode is its **inverse**. `book_flight` is a client-side HITL tool: calling it
|
||||
interrupts the run and emits an `assistant` message with a `tool_call`, then waits for the
|
||||
UI to return a result when the traveler clicks **Confirm & book** / **Cancel**. If the
|
||||
traveler instead sends another chat message, that `tool_call` is never answered, so the
|
||||
replayed history carries an `assistant(tool_calls)` with **no following `tool` result** →
|
||||
OpenAI 400:
|
||||
|
||||
```
|
||||
An assistant message with 'tool_calls' must be followed by tool messages responding to
|
||||
each 'tool_call_id'. The following tool_call_ids did not have response messages: call_…
|
||||
(param: messages.[N].role)
|
||||
```
|
||||
|
||||
`_repair_dangling_tool_calls` (`server.py`) fixes this: before handing the client's history
|
||||
to the graph, for any assistant `tool_call` with no following `tool` result it inserts a
|
||||
synthetic _"not completed"_ `tool` result right after the assistant message, so the sequence
|
||||
is valid and the model answers the new question gracefully. Reproduced + verified in-browser
|
||||
(book conversationally → Confirm card → ask something else → previously 400, now answers).
|
||||
Only the conversational booking path triggers it; the flight-card "Select this flight" path
|
||||
books client-side with no agent HITL.
|
||||
|
||||
This is a workaround, not a fix: it lives in cookbook code and reaches into a private
|
||||
adapter function. Remove it once the upstream adapter records `ToolExecutionRequest`s so
|
||||
the emitted tool-call ids correlate (the "Suggested direction" above). Pin to a fixed
|
||||
adapter commit and re-test as the integration matures.
|
||||
|
||||
## Environment
|
||||
|
||||
- `ag-ui-agent-spec` installed from `git+https://github.com/ag-ui-protocol/ag-ui.git#subdirectory=integrations/agent-spec/python` (`[langgraph]` extra)
|
||||
- `pyagentspec 26.2.0.dev6`, langgraph runtime, `langchain-openai`, Python 3.12
|
||||
- Frontend: CopilotKit V2 (`0.0.0-mme-ag-ui-0-0-46-…`), `@ag-ui/client ^0.0.46`
|
||||
- Model: an OpenAI chat model via `OpenAiCompatibleConfig` (key from env)
|
||||
@@ -0,0 +1,3 @@
|
||||
# URL of the Python Agent Spec agent's AG-UI streaming endpoint.
|
||||
# Must match the path mounted in agent/concierge/server.py (default /run).
|
||||
AGENT_URL=http://localhost:8000/run
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
# Standalone showcase: install with `npm install` (no committed lockfile).
|
||||
package-lock.json
|
||||
@@ -0,0 +1,70 @@
|
||||
# End-to-end tests (Playwright)
|
||||
|
||||
These tests drive the **real CopilotKit (V2) chat UI** against the **live Agent
|
||||
Spec agent** (LangGraph over AG-UI) and **Oracle AI Database**, and record every
|
||||
run to video.
|
||||
|
||||
## What's covered
|
||||
|
||||
| Spec | Proves |
|
||||
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `concierge.spec.ts` › recalls a preference in a brand-new session | A unique fact (`FlyHigh-<ts>` program → `ZEPHYR-<ts>` number) taught in one thread is recalled after clicking **+ New thread** (same browser session, fresh conversation) — durable **user-scoped** memory in the Agent Spec stack, via Oracle. Runs first so it sees the freshly-reset store. |
|
||||
| `concierge.spec.ts` › finds a flight in a single turn | A first turn drives the Agent Spec server tools (`recall_memory` + `search_flights`) end to end; the assertion checks details from the canonical flight (`$740` / `KLM` / `AMS-001`, nonstop), which only appear in the assistant's reply. |
|
||||
| `concierge.spec.ts` › confirms before booking (HITL, single-run) | `book_flight` is a frontend **ClientTool**, so the confirm card → boarding pass resolves within **one** agent run (no second turn) — the adapter bug below is never triggered. Passes. |
|
||||
|
||||
## Determinism
|
||||
|
||||
`global-setup.ts` clears the demo user's memory before the suite (via
|
||||
`e2e/reset-memory.py`). The concierge recalls through a **model-driven**
|
||||
`recall_memory` tool and persists _every_ turn, so a retried recall would store
|
||||
an "I don't have it" reply that poisons the next attempt; the cross-session test
|
||||
therefore does **one** clean recall against the reset store, after settling so
|
||||
the post-run memory write commits. **Heads-up:** the reset wipes `demo-user`'s
|
||||
stored memories on every run.
|
||||
|
||||
### Known issue: multi-turn
|
||||
|
||||
A _second_ user turn in the same thread after a server-tool call trips an
|
||||
upstream Agent Spec × AG-UI adapter bug (`tool_call_id` correlation). The
|
||||
concierge sidesteps it: HITL booking runs as a **single** turn (`book_flight`
|
||||
is a frontend ClientTool resolved in-run), and cross-session recall uses a
|
||||
**new thread** rather than a follow-up turn — so every spec above is a first
|
||||
turn. Details + repro:
|
||||
[`docs/known-issues/agentspec-multiturn-toolcall-correlation.md`](../../docs/known-issues/agentspec-multiturn-toolcall-correlation.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
From the repo root, with Oracle AI Database running and provisioned:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
./db/setup-db.sh
|
||||
```
|
||||
|
||||
The Playwright config (`../playwright.config.ts`) starts and **reuses** the rest:
|
||||
|
||||
- the **concierge agent** on `:8001` — a non-default port, so it won't collide
|
||||
with a manual `npm run dev` agent on `:8000`; the config points the
|
||||
frontend's `AGENT_URL` at `:8001/run` automatically, and
|
||||
- the **frontend** on a dedicated test port `:3200`.
|
||||
|
||||
The agent's `.env` (with `OPENAI_API_KEY`) must be set up per the
|
||||
[agent README](../../agent).
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install # first time — pulls in @playwright/test
|
||||
npx playwright install chromium # first time — downloads the browser
|
||||
npm run test:e2e # run headless, record video
|
||||
npm run test:e2e:headed # watch it drive the browser
|
||||
npm run test:e2e:report # open the HTML report (video + trace)
|
||||
```
|
||||
|
||||
## Videos
|
||||
|
||||
Every test records a `.webm` (gitignored) under `test-results/<test>/`. The
|
||||
cross-session recall test now runs in a single browser context (teach, then
|
||||
**+ New thread**, then recall), so it records one `video.webm` like the other
|
||||
specs. The HTML report embeds the video and, on failure, a trace.
|
||||
@@ -0,0 +1,169 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
openChat,
|
||||
newThread,
|
||||
sendMessage,
|
||||
sendAndAwaitRun,
|
||||
askUntilReply,
|
||||
assertNoAgentError,
|
||||
} from "./helpers";
|
||||
|
||||
// Unique to *this* test run: a distinctive PROGRAM (the key — it appears in both
|
||||
// the teaching message and the question) and FF_NUMBER (the answer — only in the
|
||||
// teaching message and the recalled reply). The unique key lets semantic recall
|
||||
// pin exactly this run's memory even though `demo-user` accumulates facts across
|
||||
// runs and across both cookbook projects (which share the user).
|
||||
const RUN = `${Date.now()}`;
|
||||
const PROGRAM = `FlyHigh-${RUN}`;
|
||||
const FF_NUMBER = `ZEPHYR-${RUN}`;
|
||||
|
||||
test.describe("Travel Concierge · Oracle Agent Spec × Memory", () => {
|
||||
// Runs first, against the freshly-reset store (global-setup.ts). The concierge
|
||||
// recalls through a *model-driven* `recall_memory` tool, and every turn —
|
||||
// including a failed recall — is persisted; so a retry would persist an "I
|
||||
// don't have it" reply that poisons the next attempt. We therefore do ONE
|
||||
// clean recall, after ensuring the taught fact is committed.
|
||||
test("recalls a preference in a brand-new session (cross-session memory)", async ({
|
||||
page,
|
||||
}) => {
|
||||
// ── Session A — store a unique fact. The concierge persists the turn in a
|
||||
// background task after the stream closes, then Oracle Agent Memory extracts
|
||||
// + embeds + indexes it asynchronously, so the fact is not instantly
|
||||
// recallable (we poll for it below before recalling).
|
||||
await openChat(page);
|
||||
await sendAndAwaitRun(
|
||||
page,
|
||||
`Please remember that my ${PROGRAM} frequent flyer number is ${FF_NUMBER}.`,
|
||||
);
|
||||
await assertNoAgentError(page);
|
||||
|
||||
// Block until the fact is actually searchable in Oracle (polling the same
|
||||
// memory.search path recall_memory uses) before starting a fresh thread — a
|
||||
// fixed sleep races the async indexing pipeline and makes recall flaky.
|
||||
const agentDir = path.join(__dirname, "..", "..", "agent");
|
||||
const waitScript = path.join(__dirname, "wait-until-searchable.py");
|
||||
try {
|
||||
execFileSync(
|
||||
"uv",
|
||||
["run", "--directory", agentDir, "python", waitScript, FF_NUMBER],
|
||||
{ encoding: "utf8", stdio: "pipe", timeout: 150_000 },
|
||||
);
|
||||
} catch (err) {
|
||||
const e = err as { stderr?: string; stdout?: string; message: string };
|
||||
throw new Error(
|
||||
`Taught fact never became searchable in Oracle: ${e.stderr || e.stdout || e.message}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recall — open a new thread via the sidebar. A new thread remounts
|
||||
// CopilotChat with a fresh threadId, so the only source for the number is
|
||||
// user-scoped Oracle memory recalled by recall_memory. One attempt, no
|
||||
// retry (see comment at top of describe block).
|
||||
await newThread(page);
|
||||
await askUntilReply(
|
||||
page,
|
||||
`What is my ${PROGRAM} frequent flyer number? Use what you remember about me.`,
|
||||
[new RegExp(FF_NUMBER, "i")],
|
||||
{ attempts: 1, perAttemptMs: 120_000 },
|
||||
);
|
||||
await assertNoAgentError(page);
|
||||
});
|
||||
|
||||
test("finds a flight in a single turn (recall_memory + search_flights)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openChat(page);
|
||||
// Exercises the server tools: recalls preferences, then searches flights.
|
||||
// Assert on details from the canonical Amsterdam flight (AMS-001: KLM KL606,
|
||||
// SFO → AMS, nonstop, $740) — these come from the assistant's reply, not
|
||||
// the user's question (which only says "Amsterdam"), so this proves the
|
||||
// search_flights tool actually ran and the model presented its result.
|
||||
// One attempt, no retry: a retry would be a *second* turn after this turn's
|
||||
// server tools ran, which trips the upstream multi-turn tool_call_id bug and
|
||||
// can never succeed — so retrying only guarantees failure.
|
||||
await askUntilReply(
|
||||
page,
|
||||
"Find me a flight to Amsterdam.",
|
||||
[/740|KLM|AMS-001|nonstop/i],
|
||||
{ attempts: 1, perAttemptMs: 120_000 },
|
||||
);
|
||||
await assertNoAgentError(page);
|
||||
});
|
||||
|
||||
// HITL booking — works as a single run because `book_flight` is a frontend
|
||||
// ClientTool: the confirmation card is rendered by the UI and resolved within
|
||||
// the same agent run (no second user turn, so the upstream Agent Spec × AG-UI
|
||||
// adapter bug with tool_call_id correlation is never triggered). Previously
|
||||
// tracked in:
|
||||
// docs/known-issues/agentspec-multiturn-toolcall-correlation.md
|
||||
test("confirms before booking (HITL, single-run ClientTool)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openChat(page);
|
||||
// A fresh thread is not strictly required here (this is the first interaction
|
||||
// in the test), but newThread() would also work if isolation is needed later.
|
||||
// One attempt, no retry: the booking ask runs recall_memory (a server tool)
|
||||
// in this turn, so a retry would be a second turn and trip the upstream
|
||||
// multi-turn bug. Give the single attempt a generous window instead.
|
||||
await askUntilReply(
|
||||
page,
|
||||
"Book me flight AMS-001 to Amsterdam.",
|
||||
[/confirm your booking|confirm & book/i],
|
||||
{ attempts: 1, perAttemptMs: 120_000 },
|
||||
);
|
||||
// Click the generative-UI confirmation card button surfaced by the ClientTool.
|
||||
await page.getByRole("button", { name: /confirm & book/i }).click();
|
||||
// Assert the boarding-pass badge ("CONFIRMED ✓"), not the echoed respond-payload
|
||||
// string ("CONFIRMED — booked …"). The ✓ glyph appears only in the badge, so
|
||||
// this fails before the run resolves instead of passing off the echoed payload.
|
||||
await expect(page.getByText(/CONFIRMED ✓/)).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await assertNoAgentError(page);
|
||||
});
|
||||
|
||||
// The card-click booking path — distinct from the conversational HITL path
|
||||
// above. Selecting a flight drives confirm → book entirely client-side in
|
||||
// FlightOptions (no agent turn), so the confirm card renders inline in view
|
||||
// and nothing is appended to the chat. Regression guard for the "select does
|
||||
// nothing / confirm card scrolled off-screen" bug: the old path injected a
|
||||
// "Book me flight …" user message and ran the agent; here we assert NO such
|
||||
// message is ever appended.
|
||||
test("books inline from the flight card (client-side select → confirm → book)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openChat(page);
|
||||
// Render the flight cards (search_flights genUI). One attempt, no retry: this
|
||||
// turn runs server tools, so a retry would trip the upstream multi-turn bug.
|
||||
await sendMessage(page, "Find me a flight to Amsterdam.");
|
||||
const selectBtn = () =>
|
||||
page.getByRole("button", { name: /select this flight/i }).first();
|
||||
await expect(selectBtn()).toBeVisible({ timeout: 120_000 });
|
||||
|
||||
// Select → inline confirm card, with no agent round-trip (no injected message).
|
||||
await selectBtn().click();
|
||||
await expect(page.getByText(/confirm your booking/i)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByText(/book me flight/i)).toHaveCount(0);
|
||||
|
||||
// Cancel → back to the flight list.
|
||||
await page.getByRole("button", { name: /^cancel$/i }).click();
|
||||
await expect(selectBtn()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Select again → confirm & book → boarding pass, still no agent turn.
|
||||
await selectBtn().click();
|
||||
await expect(page.getByText(/confirm your booking/i)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByRole("button", { name: /confirm & book/i }).click();
|
||||
await expect(page.getByText(/CONFIRMED ✓/)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByText(/book me flight/i)).toHaveCount(0);
|
||||
await assertNoAgentError(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
// CopilotKit (V2) exposes stable test ids on the composer and send button.
|
||||
const TEXTAREA = "copilot-chat-textarea";
|
||||
const SEND_BUTTON = "copilot-send-button";
|
||||
|
||||
/** Load the app and wait for the chat composer to be interactive. */
|
||||
export async function openChat(page: Page): Promise<void> {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId(TEXTAREA)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** Type `text` into the composer and send it by clicking the send button. */
|
||||
export async function sendMessage(page: Page, text: string): Promise<void> {
|
||||
const input = page.getByTestId(TEXTAREA);
|
||||
await input.click();
|
||||
await input.fill(text);
|
||||
// The send button enables once the composer is non-empty AND the component has
|
||||
// hydrated. Waiting for that (rather than pressing Enter) avoids a first-
|
||||
// interaction race where Enter no-ops before hydration completes.
|
||||
const send = page.getByTestId(SEND_BUTTON);
|
||||
await expect(send).toBeEnabled({ timeout: 15_000 });
|
||||
await send.click();
|
||||
// Sending clears the composer — a reliable signal the message was submitted.
|
||||
await expect(input).toHaveValue("", { timeout: 10_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message and wait for the agent run's response stream to finish.
|
||||
*
|
||||
* NOTE: persistence is no longer coupled to stream close. The concierge writes
|
||||
* memory in a background task AFTER the SSE stream closes at RUN_FINISHED, so a
|
||||
* finished response is NOT a "memory written" signal. Callers that need the turn
|
||||
* to be recallable must poll for it separately (see wait-until-searchable.py in
|
||||
* the cross-session test); this only waits for the run to complete.
|
||||
*/
|
||||
export async function sendAndAwaitRun(page: Page, text: string): Promise<void> {
|
||||
const streamClosed = page
|
||||
.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/api/copilotkit") && r.request().method() === "POST",
|
||||
{ timeout: 150_000 },
|
||||
)
|
||||
.then((r) => r.finished());
|
||||
await sendMessage(page, text);
|
||||
await streamClosed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a question and retry until the reply contains every expected pattern.
|
||||
*
|
||||
* IMPORTANT — retrying is unsafe for tool-driven prompts in this app.
|
||||
* Each retry re-sends the question as a *new turn* in the same thread. After
|
||||
* a server-tool call, that second turn trips the upstream multi-turn
|
||||
* tool_call_id correlation bug and can never succeed. The default is therefore
|
||||
* `attempts = 1`. Callers who need more time for a tool-driven prompt should
|
||||
* raise `perAttemptMs` instead of `attempts`; `attempts > 1` is only safe for
|
||||
* purely conversational (non-tool-driven) prompts.
|
||||
*/
|
||||
export async function askUntilReply(
|
||||
page: Page,
|
||||
question: string,
|
||||
patterns: RegExp[],
|
||||
{
|
||||
attempts = 1,
|
||||
perAttemptMs = 90_000,
|
||||
gapMs = 3_000,
|
||||
}: { attempts?: number; perAttemptMs?: number; gapMs?: number } = {},
|
||||
): Promise<void> {
|
||||
let missing: RegExp[] = patterns;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await sendMessage(page, question);
|
||||
const visible = await Promise.all(
|
||||
patterns.map(async (p) => {
|
||||
try {
|
||||
await expect(page.getByText(p).first()).toBeVisible({
|
||||
timeout: perAttemptMs,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
missing = patterns.filter((_, idx) => !visible[idx]);
|
||||
if (missing.length === 0) return;
|
||||
if (i < attempts - 1) await page.waitForTimeout(gapMs);
|
||||
}
|
||||
throw new Error(
|
||||
`No reply matching ${missing.map(String).join(", ")} after ${attempts} attempts`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Fail fast if the runtime surfaced an error (incl. the known multi-turn bug). */
|
||||
export async function assertNoAgentError(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByText(
|
||||
/RUN_ERROR|agent_run_error|fetch failed|must be a response to/i,
|
||||
),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the sidebar "New thread" button and wait for the chat composer to be
|
||||
* ready in the fresh, empty conversation. Each new thread mounts a new
|
||||
* CopilotChat instance with its own threadId, so any prior server-tool state
|
||||
* is isolated — use this instead of opening a new browser context when you
|
||||
* only need a clean conversation, not a clean browser session.
|
||||
*/
|
||||
export async function newThread(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: /new thread/i })
|
||||
.first()
|
||||
.click();
|
||||
// The composer textarea must be present and empty before we start typing.
|
||||
const input = page.getByTestId(TEXTAREA);
|
||||
await expect(input).toBeVisible({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue("", { timeout: 10_000 });
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Test support — purge one user's durable memory so the cross-session E2E tests
|
||||
are deterministic regardless of prior runs.
|
||||
|
||||
Memory extraction is an LLM step: with many similar facts accumulated under the
|
||||
shared demo user, it can conflate them (e.g. pair this run's unique key with an
|
||||
older run's value), which makes recall non-deterministic. Clearing the user's
|
||||
records before the suite removes that pollution.
|
||||
|
||||
`oracleagentmemory` keys its records by USER_ID, so a scoped DELETE across its
|
||||
tables is a clean, well-defined purge (the VECTOR$ index on RECORD_CHUNKS
|
||||
self-maintains on DML). Run automatically from `global-setup.ts`.
|
||||
|
||||
Usage: python reset-memory.py [user_id] (default: demo-user)
|
||||
Needs ORACLE_DB_* in the agent's .env; run via the agent venv (`uv run`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# The agent's .env (…/<project>/agent/.env) is not an ancestor of this script, so
|
||||
# point python-dotenv at it explicitly rather than relying on its search path.
|
||||
_AGENT_ENV = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "..", "agent", ".env"
|
||||
)
|
||||
load_dotenv(_AGENT_ENV)
|
||||
|
||||
USER = sys.argv[1] if len(sys.argv) > 1 else "demo-user"
|
||||
# Children first; the VECTOR$ index on RECORD_CHUNKS auto-maintains on delete.
|
||||
TABLES = ("RECORD_CHUNKS", "MEMORY", "MESSAGE", "THREAD")
|
||||
|
||||
try:
|
||||
import oracledb
|
||||
|
||||
conn = oracledb.connect(
|
||||
user=os.environ["ORACLE_DB_USER"],
|
||||
password=os.environ["ORACLE_DB_PASSWORD"],
|
||||
dsn=os.environ["ORACLE_DB_DSN"],
|
||||
)
|
||||
except Exception as exc: # DB down / not provisioned — let the tests surface it
|
||||
print(f"[reset-memory] skipped: cannot connect ({exc})")
|
||||
sys.exit(0)
|
||||
|
||||
cur = conn.cursor()
|
||||
deleted: dict[str, object] = {}
|
||||
for table in TABLES:
|
||||
try:
|
||||
cur.execute(f'DELETE FROM "{table}" WHERE USER_ID = :1', [USER])
|
||||
deleted[table] = cur.rowcount
|
||||
except Exception as exc:
|
||||
# ORA-00942 (table not created yet) on a fresh database is fine.
|
||||
deleted[table] = f"skip ({type(exc).__name__})"
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[reset-memory] cleared {USER!r}: {deleted}")
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { openChat, sendMessage, newThread } from "./helpers";
|
||||
|
||||
// Regression for the reported bug: "New conversation thread name is the same as
|
||||
// the prior conversation name." The old ThreadTitler ran a useEffect keyed on
|
||||
// activeThreadId and read the shared, agentId-scoped agent.messages — on a thread
|
||||
// switch that still held the PREVIOUS thread's transcript, so a freshly created
|
||||
// thread was named after the prior conversation. The fix drives titling off the
|
||||
// agent's own events (ThreadTitler subscribes via agent.subscribe to
|
||||
// onMessagesChanged/onRunStartedEvent), gated on agent.threadId === activeThreadId,
|
||||
// so a thread is only ever named from its own transcript.
|
||||
//
|
||||
// Titling fires when the user's message is added / the run starts — it does NOT wait
|
||||
// for the agent's reply, so this test is fast and unaffected by memory/LLM latency.
|
||||
const ITEM = '[data-testid="thread-item"]';
|
||||
const ACTIVE = '[data-testid="thread-item"][data-active="true"]';
|
||||
|
||||
test.describe("Thread titles", () => {
|
||||
test("a new thread is NOT named after the previous conversation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openChat(page);
|
||||
|
||||
// Thread 1 is titled from its own first message.
|
||||
const msg1 = "Plan a trip to Tokyo next spring";
|
||||
await sendMessage(page, msg1);
|
||||
await expect(page.locator(ACTIVE)).toContainText(msg1, { timeout: 15_000 });
|
||||
|
||||
// Start a new thread. The active thread MUST be the default title — not
|
||||
// thread 1's title (the bug). And thread 1's title must be untouched.
|
||||
await newThread(page);
|
||||
await expect(page.locator(ACTIVE)).toContainText("New conversation", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.locator(ITEM).filter({ hasText: msg1 })).toHaveCount(1);
|
||||
|
||||
// The new thread is titled from ITS OWN first message, leaving thread 1 intact.
|
||||
const msg2 = "Find hotels in Paris near the Louvre";
|
||||
await sendMessage(page, msg2);
|
||||
await expect(page.locator(ACTIVE)).toContainText(msg2, { timeout: 15_000 });
|
||||
await expect(page.locator(ITEM).filter({ hasText: msg1 })).toHaveCount(1);
|
||||
await expect(page.locator(ITEM)).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Test support — block until a just-taught fact is recallable from Oracle.
|
||||
|
||||
The Agent Spec memory pipeline is asynchronous: after a turn is persisted, Oracle
|
||||
Agent Memory extracts, embeds, and indexes it before it can be retrieved. A fact
|
||||
taught moments ago is therefore not instantly searchable. The cross-session E2E
|
||||
test must wait for that pipeline before asking in a fresh session — otherwise it
|
||||
races indexing and recall returns nothing (a flaky failure that looks like a
|
||||
product bug but is just a too-short delay).
|
||||
|
||||
This polls the SAME path `recall_memory` uses (`memory.search`) until the unique
|
||||
token appears, then exits 0. Including the token in the query makes this a
|
||||
reliable "is it indexed yet?" probe: the token can only appear in a result once
|
||||
the fact is stored, so there are no false positives.
|
||||
|
||||
Usage: python wait-until-searchable.py <token> [user_id] [timeout_seconds]
|
||||
Run via the agent venv: uv run --directory agent python <this> <token>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# This helper lives in frontend/e2e/; the agent (its package + .env + venv deps)
|
||||
# is two levels up. Put it on the path and load its .env explicitly.
|
||||
_AGENT_DIR = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "..", "agent"
|
||||
)
|
||||
sys.path.insert(0, _AGENT_DIR)
|
||||
|
||||
from dotenv import load_dotenv # noqa: E402
|
||||
|
||||
load_dotenv(os.path.join(_AGENT_DIR, ".env"))
|
||||
|
||||
TOKEN = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
USER = sys.argv[2] if len(sys.argv) > 2 else "demo-user"
|
||||
TIMEOUT = float(sys.argv[3]) if len(sys.argv) > 3 else 120.0
|
||||
POLL = 3.0
|
||||
|
||||
if not TOKEN:
|
||||
print("[wait-until-searchable] no token given", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
from concierge.memory import get_memory # noqa: E402
|
||||
from oracleagentmemory.apis.searchscope import SearchScope # noqa: E402
|
||||
|
||||
memory = get_memory()
|
||||
scope = SearchScope(user_id=USER)
|
||||
query = f"frequent flyer number {TOKEN}"
|
||||
|
||||
deadline = time.monotonic() + TIMEOUT
|
||||
attempt = 0
|
||||
while time.monotonic() < deadline:
|
||||
attempt += 1
|
||||
try:
|
||||
results = list(memory.search(query=query, scope=scope))
|
||||
except Exception as exc: # transient (index building, pool warm-up) — retry
|
||||
print(
|
||||
f"[wait-until-searchable] attempt {attempt}: {type(exc).__name__}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
results = []
|
||||
if any(TOKEN.lower() in (getattr(r, "content", "") or "").lower() for r in results):
|
||||
elapsed = int(TIMEOUT - (deadline - time.monotonic()))
|
||||
print(f"[wait-until-searchable] {TOKEN!r} searchable after ~{elapsed}s ({attempt} polls)")
|
||||
sys.exit(0)
|
||||
time.sleep(POLL)
|
||||
|
||||
print(f"[wait-until-searchable] {TOKEN!r} NOT searchable within {TIMEOUT:.0f}s", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,18 @@
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTypescript from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = [
|
||||
...nextCoreWebVitals,
|
||||
...nextTypescript,
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
// Purge the demo user's durable memory before the suite so the cross-session
|
||||
// recall test is deterministic (no stale facts from earlier runs). Runs the
|
||||
// reset through the agent's venv via `uv`. Non-fatal: if it can't run, the
|
||||
// suite still runs and any real DB problem surfaces in the tests themselves.
|
||||
export default function globalSetup() {
|
||||
const frontendDir = __dirname;
|
||||
const agentDir = path.join(frontendDir, "..", "agent");
|
||||
const script = path.join(frontendDir, "e2e", "reset-memory.py");
|
||||
try {
|
||||
const out = execFileSync(
|
||||
"uv",
|
||||
["run", "--directory", agentDir, "python", script],
|
||||
{ encoding: "utf8", stdio: "pipe" },
|
||||
);
|
||||
process.stdout.write(out);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[global-setup] memory reset skipped: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "agentspec-memory-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "^0.0.46",
|
||||
"@ag-ui/core": "^0.0.46",
|
||||
"@ag-ui/encoder": "^0.0.46",
|
||||
"@ag-ui/proto": "^0.0.46",
|
||||
"@copilotkit/react-core": "0.0.0-mme-ag-ui-0-0-46-20260227141603",
|
||||
"@copilotkit/react-ui": "0.0.0-mme-ag-ui-0-0-46-20260227141603",
|
||||
"@copilotkit/runtime": "0.0.0-mme-ag-ui-0-0-46-20260227141603",
|
||||
"@copilotkit/runtime-client-gql": "0.0.0-mme-ag-ui-0-0-46-20260227141603",
|
||||
"@copilotkit/shared": "0.0.0-mme-ag-ui-0-0-46-20260227141603",
|
||||
"hono": "^4.11.4",
|
||||
"next": "16.0.10",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"zod": "^3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
// End-to-end tests drive the real CopilotKit (V2) chat UI against the live Agent
|
||||
// Spec agent (LangGraph over AG-UI) and Oracle AI Database. Recorded to video.
|
||||
//
|
||||
// This agent runs on :8001 so it won't collide with a manual dev agent on :8000
|
||||
// (the agent defaults to :8000), so we override the frontend's AGENT_URL here.
|
||||
//
|
||||
// Prerequisites (reused if already running):
|
||||
// 1. Oracle AI Database up + `cookbook` user provisioned (repo-root README).
|
||||
// 2. The concierge agent on :8001 — Playwright starts it if it isn't.
|
||||
|
||||
const FRONTEND_PORT = 3200;
|
||||
const AGENT_PORT = 8001;
|
||||
const AGENT_URL = `http://127.0.0.1:${AGENT_PORT}/run`;
|
||||
const agentDir = path.join(__dirname, "..", "agent");
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
outputDir: "./test-results",
|
||||
// Clear the demo user's memory before the suite so cross-session recall is
|
||||
// deterministic (see global-setup.ts).
|
||||
globalSetup: "./global-setup.ts",
|
||||
// Tests share one server-side memory store (the `demo-user`); run them in order.
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
// Agent Spec turns (recall + tool calls + LLM) are slow, and the cross-session
|
||||
// test runs two of them back to back.
|
||||
timeout: 300_000,
|
||||
expect: { timeout: 120_000 },
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
use: {
|
||||
baseURL: `http://localhost:${FRONTEND_PORT}`,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
video: { mode: "on", size: { width: 1280, height: 720 } },
|
||||
trace: "retain-on-failure",
|
||||
actionTimeout: 30_000,
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: [
|
||||
{
|
||||
command: `uv run python -m uvicorn concierge.server:app --host 127.0.0.1 --port ${AGENT_PORT}`,
|
||||
cwd: agentDir,
|
||||
url: `http://127.0.0.1:${AGENT_PORT}/health`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
{
|
||||
command: `npm run dev -- --port ${FRONTEND_PORT}`,
|
||||
url: `http://localhost:${FRONTEND_PORT}`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: { AGENT_URL },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://railway.com/railway.schema.json",
|
||||
"build": { "builder": "NIXPACKS" },
|
||||
"deploy": {
|
||||
"startCommand": "npm run start -- --hostname 0.0.0.0 --port $PORT",
|
||||
"restartPolicyType": "ON_FAILURE"
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotEndpoint,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { handle } from "hono/vercel";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
// Proxy to the Python Agent Spec agent (LangGraph) over AG-UI. The agent owns
|
||||
// the LLM and the memory, so no service adapter / LLM key lives here.
|
||||
const agent = new HttpAgent({
|
||||
url:
|
||||
process.env.AGENT_URL ||
|
||||
process.env.NEXT_PUBLIC_AGENT_URL ||
|
||||
"http://localhost:8000/run",
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { oracle_concierge: agent },
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const app = createCopilotEndpoint({ runtime, basePath: "/api/copilotkit" });
|
||||
|
||||
export const GET = handle(app);
|
||||
export const POST = handle(app);
|
||||
@@ -0,0 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Oracle Agent Spec × Memory × CopilotKit",
|
||||
description:
|
||||
"A travel concierge with long-term memory on Oracle AI Database.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import "@copilotkit/react-core/v2/styles.css";
|
||||
import { CopilotKitProvider, CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useThreadStore } from "@/lib/threads";
|
||||
import { ThreadSidebar } from "@/components/ThreadSidebar";
|
||||
import { ConciergeTools } from "@/components/ConciergeTools";
|
||||
import { ErrorNotice } from "@/components/ErrorNotice";
|
||||
import { ThreadTitler } from "@/components/ThreadTitler";
|
||||
|
||||
export default function Home() {
|
||||
const {
|
||||
ready,
|
||||
threads,
|
||||
activeThreadId,
|
||||
newThread,
|
||||
selectThread,
|
||||
renameThread,
|
||||
} = useThreadStore();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const activeTitle = threads.find((t) => t.id === activeThreadId)?.title ?? "";
|
||||
|
||||
return (
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit">
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<ThreadSidebar
|
||||
threads={threads}
|
||||
activeThreadId={activeThreadId}
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed((c) => !c)}
|
||||
onNewThread={newThread}
|
||||
onSelectThread={selectThread}
|
||||
/>
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<header className="border-b border-gray-200 bg-white px-6 py-3 shrink-0">
|
||||
<h1 className="text-lg font-semibold text-gray-900 leading-tight">
|
||||
Travel Concierge · Oracle Agent Spec × Memory
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Ask about destinations, get personalized recommendations, and let
|
||||
the agent remember your preferences across sessions.{" "}
|
||||
<span className="text-gray-400">
|
||||
Tip: start a New thread to test cross-session memory.
|
||||
</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Tool renderers — mount once, render inline in the chat stream */}
|
||||
<ConciergeTools />
|
||||
|
||||
{/* Surfaces run errors the chat UI would otherwise swallow */}
|
||||
<ErrorNotice />
|
||||
|
||||
{/* Names a thread after its first user message */}
|
||||
<ThreadTitler
|
||||
activeThreadId={activeThreadId}
|
||||
activeTitle={activeTitle}
|
||||
onTitle={renameThread}
|
||||
/>
|
||||
|
||||
{/* Chat region */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{ready && activeThreadId ? (
|
||||
<CopilotChat
|
||||
agentId="oracle_concierge"
|
||||
threadId={activeThreadId}
|
||||
key={activeThreadId}
|
||||
className="h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import type { Flight } from "@/lib/flights";
|
||||
import { formatTime } from "@/lib/flights";
|
||||
|
||||
interface BoardingPassProps {
|
||||
flight?: Flight;
|
||||
flightId: string;
|
||||
booked?: boolean;
|
||||
}
|
||||
|
||||
export function BoardingPass({ flight, flightId, booked }: BoardingPassProps) {
|
||||
return (
|
||||
<div className="mt-3 rounded-xl overflow-hidden border border-gray-200 bg-white shadow-sm max-w-lg">
|
||||
{/* Colored top stripe */}
|
||||
<div className="h-2 bg-indigo-600" />
|
||||
|
||||
<div className="flex divide-x divide-dashed divide-gray-300">
|
||||
{/* Main section */}
|
||||
<div className="flex-1 p-5 space-y-3">
|
||||
{flight ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide">
|
||||
Airline
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{flight.airline}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-gray-400">
|
||||
{flight.flight_no}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-0.5">
|
||||
Route
|
||||
</p>
|
||||
<p className="text-xl font-bold text-gray-900 tracking-tight">
|
||||
{flight.origin} → {flight.destination}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide">
|
||||
Departs
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-900 tabular-nums">
|
||||
{formatTime(flight.depart)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide">
|
||||
Arrives
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-900 tabular-nums">
|
||||
{formatTime(flight.arrive)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide">
|
||||
Duration
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">{flight.duration}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide">
|
||||
Class
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">{flight.cabin}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-1">
|
||||
Flight
|
||||
</p>
|
||||
<p className="font-mono text-sm text-gray-700">{flightId}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stub section */}
|
||||
<div className="w-36 p-4 flex flex-col justify-between bg-gray-50/60">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide">
|
||||
Seat
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-900">Aisle</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide">
|
||||
Gate
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-900">—</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide">
|
||||
Boarding
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-900">—</p>
|
||||
</div>
|
||||
{flight && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide">
|
||||
Price
|
||||
</p>
|
||||
<p className="text-sm font-bold text-indigo-600 tabular-nums">
|
||||
{typeof flight?.price_usd === "number"
|
||||
? `$${flight.price_usd.toLocaleString()}`
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{booked && (
|
||||
<div className="mt-3">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-1 text-xs font-semibold text-emerald-700">
|
||||
CONFIRMED ✓
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Flight } from "@/lib/flights";
|
||||
import { formatTime } from "@/lib/flights";
|
||||
|
||||
interface BookingConfirmCardProps {
|
||||
flight?: Flight;
|
||||
flightId: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function BookingConfirmCard({
|
||||
flight,
|
||||
flightId,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: BookingConfirmCardProps) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-indigo-200 bg-indigo-50/40 p-5 space-y-4">
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Confirm your booking
|
||||
</h3>
|
||||
|
||||
{flight ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{flight.origin} → {flight.destination}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span className="font-medium">{flight.airline}</span>
|
||||
<span className="text-gray-400 font-mono text-xs">
|
||||
{flight.flight_no}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 tabular-nums">
|
||||
<span>{formatTime(flight.depart)}</span>
|
||||
<span className="text-gray-300">–</span>
|
||||
<span>{formatTime(flight.arrive)}</span>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span>{flight.duration}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-indigo-600 tabular-nums">
|
||||
{typeof flight?.price_usd === "number"
|
||||
? `$${flight.price_usd.toLocaleString()}`
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">
|
||||
Flight{" "}
|
||||
<span className="font-mono text-xs bg-white border border-gray-200 rounded px-1.5 py-0.5">
|
||||
{flightId}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-60"
|
||||
>
|
||||
Confirm & book
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onCancel();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-400 disabled:opacity-60"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useRenderTool,
|
||||
useHumanInTheLoop,
|
||||
useDefaultRenderTool,
|
||||
useConfigureSuggestions,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
import { FlightOptions } from "@/components/FlightOptions";
|
||||
import { RecallChip } from "@/components/RecallChip";
|
||||
import { BookingConfirmCard } from "@/components/BookingConfirmCard";
|
||||
import { BoardingPass } from "@/components/BoardingPass";
|
||||
import { parseFlights, getFlight } from "@/lib/flights";
|
||||
|
||||
export function ConciergeTools() {
|
||||
// Starter-prompt chips shown on each empty thread — they walk the user through
|
||||
// the whole demo: teach prefs → search (cards) → book (HITL) → recall in a new thread.
|
||||
useConfigureSuggestions({
|
||||
available: "before-first-message",
|
||||
suggestions: [
|
||||
{
|
||||
title: "Set my travel prefs",
|
||||
message:
|
||||
"Remember that I fly out of SFO, prefer aisle seats, and like vegetarian meals.",
|
||||
},
|
||||
{
|
||||
title: "Find a flight to Amsterdam",
|
||||
message: "Find me a flight to Amsterdam.",
|
||||
},
|
||||
{
|
||||
title: "Book the nonstop",
|
||||
message: "Book me flight AMS-001 to Amsterdam.",
|
||||
},
|
||||
{
|
||||
title: "What do you remember?",
|
||||
message: "What do you remember about my travel preferences?",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useRenderTool({
|
||||
name: "search_flights",
|
||||
parameters: z.object({ destination: z.string() }),
|
||||
render: ({ status, parameters, result }) => {
|
||||
if (status !== "complete") {
|
||||
return (
|
||||
<p className="text-sm text-gray-500 py-2">
|
||||
Searching flights to {parameters?.destination ?? "your destination"}
|
||||
…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return <FlightOptions flights={parseFlights(result as string)} />;
|
||||
},
|
||||
});
|
||||
|
||||
useRenderTool({
|
||||
name: "recall_memory",
|
||||
parameters: z.object({ query: z.string() }),
|
||||
render: ({ status, result }) => (
|
||||
<RecallChip
|
||||
memories={status === "complete" ? (result as string) : undefined}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
useHumanInTheLoop({
|
||||
name: "book_flight",
|
||||
description:
|
||||
"Confirm with the traveler, then book the chosen flight by its id.",
|
||||
parameters: z.object({ flight_id: z.string() }),
|
||||
render: ({ status, args, respond }) => {
|
||||
const id = (args?.flight_id as string) ?? "";
|
||||
if (status === "complete")
|
||||
return <BoardingPass flightId={id} flight={getFlight(id)} booked />;
|
||||
if (status !== "executing" || !respond) return <></>;
|
||||
const flight = getFlight(id);
|
||||
return (
|
||||
<BookingConfirmCard
|
||||
flight={flight}
|
||||
flightId={id}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await respond(
|
||||
`CONFIRMED — booked ${flight?.flight_no ?? id} (${id}). Confirmation sent.`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("book_flight respond failed", e);
|
||||
}
|
||||
}}
|
||||
onCancel={async () => {
|
||||
try {
|
||||
await respond(
|
||||
"CANCELLED — the traveler declined; no booking was made.",
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("book_flight respond failed", e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useDefaultRenderTool();
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAgent } from "@copilotkit/react-core/v2";
|
||||
|
||||
const AGENT_ID = "oracle_concierge";
|
||||
|
||||
/** A friendlier, shorter message for known failure shapes. */
|
||||
function humanize(raw: string): string {
|
||||
if (
|
||||
/must be a response to a preceeding message with 'tool_calls'/.test(raw)
|
||||
) {
|
||||
return "This conversation hit a known multi-turn limitation in the Agent Spec × AG-UI adapter. Start a new thread to continue.";
|
||||
}
|
||||
return "The agent run failed. Please try again or start a new thread.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Surfaces a run's RUN_ERROR, which the chat UI otherwise swallows (it arrives
|
||||
* after RUN_FINISHED), so a failed turn no longer looks like a dead app.
|
||||
*/
|
||||
export function ErrorNotice() {
|
||||
const { agent } = useAgent({ agentId: AGENT_ID });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent) return;
|
||||
const { unsubscribe } = agent.subscribe({
|
||||
// Clear any prior error when a new run begins.
|
||||
onRunStartedEvent: () => setError(null),
|
||||
// SSE RUN_ERROR (e.g. the agent raised mid-run) — chat UI swallows this.
|
||||
onRunErrorEvent: ({ event }: { event: { message?: string } }) => {
|
||||
setError(event?.message || "Unknown error");
|
||||
},
|
||||
// Run threw (e.g. the agent endpoint is unreachable).
|
||||
onRunFailed: ({ error }: { error: Error }) => {
|
||||
setError(error?.message || String(error));
|
||||
},
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [agent]);
|
||||
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-4 mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-800 flex items-start gap-3">
|
||||
<span aria-hidden="true" className="mt-0.5">
|
||||
⚠️
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">{humanize(error)}</p>
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-xs text-red-600/80 hover:text-red-700">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words text-[11px] text-red-700/70 font-mono max-h-32 overflow-auto">
|
||||
{error}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setError(null)}
|
||||
aria-label="Dismiss"
|
||||
className="shrink-0 text-red-400 hover:text-red-600 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Flight } from "@/lib/flights";
|
||||
import { formatTime, stopsLabel } from "@/lib/flights";
|
||||
import { BookingConfirmCard } from "@/components/BookingConfirmCard";
|
||||
import { BoardingPass } from "@/components/BoardingPass";
|
||||
|
||||
export function FlightCard({
|
||||
flight,
|
||||
onSelect,
|
||||
}: {
|
||||
flight: Flight;
|
||||
onSelect?: (flight: Flight) => void;
|
||||
}) {
|
||||
const isNonstop = flight.stops === 0;
|
||||
const selectable = Boolean(onSelect);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border bg-white shadow-sm p-4 flex flex-col gap-3 transition-colors ${
|
||||
selectable
|
||||
? "border-gray-200 hover:border-indigo-300 hover:bg-indigo-50/30"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Left: route + times */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-gray-900 text-sm">
|
||||
{flight.airline}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 font-mono">
|
||||
{flight.flight_no}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-base font-bold text-gray-900 mb-1">
|
||||
{flight.origin} → {flight.destination}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 flex-wrap mb-2">
|
||||
<span className="tabular-nums">{formatTime(flight.depart)}</span>
|
||||
<span className="text-gray-300">–</span>
|
||||
<span className="tabular-nums">{formatTime(flight.arrive)}</span>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span>{flight.duration}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
isNonstop
|
||||
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
: "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
}`}
|
||||
>
|
||||
{stopsLabel(flight.stops)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 border border-gray-200 rounded-full px-2 py-0.5">
|
||||
{flight.cabin}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{flight.notes && (
|
||||
<p className="mt-2 text-xs text-gray-400 truncate">
|
||||
{flight.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: price + id */}
|
||||
<div className="flex flex-col items-end shrink-0 gap-1">
|
||||
<span className="text-xl font-bold text-indigo-600 tabular-nums">
|
||||
{typeof flight.price_usd === "number"
|
||||
? `$${flight.price_usd.toLocaleString()}`
|
||||
: "—"}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-300 font-mono">
|
||||
{flight.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selector — opens the booking confirm inline (no agent round-trip), so the
|
||||
confirm step renders right here in view instead of being appended below
|
||||
the fold by a fresh agent turn. */}
|
||||
{selectable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect?.(flight)}
|
||||
className="self-end inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Select this flight
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlightOptions({ flights = [] }: { flights?: Flight[] }) {
|
||||
// The whole select → confirm → book flow is local to this card: no agent run,
|
||||
// so nothing gets appended to the chat stream and the booking UI can never be
|
||||
// scrolled out of view. The conversational "Book me flight X" path still goes
|
||||
// through the agent's book_flight HITL tool.
|
||||
const [chosen, setChosen] = useState<Flight | null>(null);
|
||||
const [booked, setBooked] = useState(false);
|
||||
const focusRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep the confirm card / boarding pass in view as it replaces the list.
|
||||
useEffect(() => {
|
||||
if (chosen) focusRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [chosen, booked]);
|
||||
|
||||
if (flights.length === 0) {
|
||||
return <p className="text-sm text-gray-400 py-2">No flights found.</p>;
|
||||
}
|
||||
|
||||
if (chosen) {
|
||||
return (
|
||||
<div ref={focusRef}>
|
||||
{booked ? (
|
||||
<BoardingPass flightId={chosen.id} flight={chosen} booked />
|
||||
) : (
|
||||
<BookingConfirmCard
|
||||
flight={chosen}
|
||||
flightId={chosen.id}
|
||||
onConfirm={() => setBooked(true)}
|
||||
onCancel={() => setChosen(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">
|
||||
✈️ Flight options
|
||||
</p>
|
||||
<div className="grid gap-3">
|
||||
{flights.map((flight, i) => (
|
||||
<FlightCard
|
||||
key={flight.id ?? `${flight.flight_no ?? "flight"}-${i}`}
|
||||
flight={flight}
|
||||
onSelect={setChosen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface RecallChipProps {
|
||||
memories?: string;
|
||||
}
|
||||
|
||||
const EMPTY_SENTINEL = "No relevant memories.";
|
||||
|
||||
/** Split the newline-separated recall result into clean preference lines. */
|
||||
function parsePreferences(memories: string): string[] {
|
||||
return memories
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^[-•*]\s*/, "").trim())
|
||||
.filter((line) => line.length > 0 && line !== EMPTY_SENTINEL);
|
||||
}
|
||||
|
||||
export function RecallChip({ memories }: RecallChipProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasMemories = Boolean(memories && memories.trim().length > 0);
|
||||
|
||||
// Still recalling — non-interactive placeholder.
|
||||
if (!hasMemories) {
|
||||
return (
|
||||
<span className="text-xs rounded-full bg-gray-100 px-2.5 py-1 text-gray-600 inline-flex items-center gap-1">
|
||||
🧠 Recalling your preferences…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const prefs = parsePreferences(memories as string);
|
||||
const hasPrefs = prefs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mt-3 inline-flex flex-col items-start gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="text-xs rounded-full bg-gray-100 px-2.5 py-1 text-gray-600 inline-flex items-center gap-1 hover:bg-gray-200 transition-colors cursor-pointer"
|
||||
>
|
||||
🧠 Remembered your preferences
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className={`transition-transform ${open ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white shadow-sm px-3 py-2 text-xs text-gray-600">
|
||||
{hasPrefs ? (
|
||||
<ul className="space-y-1">
|
||||
{prefs.map((pref, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5">
|
||||
<span className="text-gray-300 leading-none mt-0.5">•</span>
|
||||
<span>{pref}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-gray-400">Nothing saved yet for this query.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import type { Thread } from "@/lib/threads";
|
||||
|
||||
interface ThreadSidebarProps {
|
||||
threads: Thread[];
|
||||
activeThreadId: string;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
onNewThread: () => void;
|
||||
onSelectThread: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ThreadSidebar({
|
||||
threads,
|
||||
activeThreadId,
|
||||
collapsed,
|
||||
onToggle,
|
||||
onNewThread,
|
||||
onSelectThread,
|
||||
}: ThreadSidebarProps) {
|
||||
return (
|
||||
<div
|
||||
className="h-full shrink-0 min-w-0 overflow-hidden border-r border-gray-200 bg-white flex flex-col"
|
||||
style={{ width: collapsed ? "3.5rem" : "16rem" }}
|
||||
>
|
||||
{collapsed ? (
|
||||
<div className="flex flex-col items-center gap-2 pt-3 px-2">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-label="Expand sidebar"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 text-gray-600 text-lg"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<button
|
||||
onClick={onNewThread}
|
||||
aria-label="New thread"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 text-gray-600 text-lg font-semibold"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 shrink-0">
|
||||
<span className="text-sm font-semibold text-gray-800 tracking-wide">
|
||||
Conversations
|
||||
</span>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-label="Collapse sidebar"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-gray-100 text-gray-500"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="10 4 6 8 10 12" />
|
||||
<polyline points="6 4 2 8 6 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-3 shrink-0">
|
||||
<button
|
||||
onClick={onNewThread}
|
||||
className="w-full bg-indigo-600 text-white rounded-lg px-3 py-2 text-sm font-medium hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
+ New thread
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-3">
|
||||
{threads.map((t) => {
|
||||
const isActive = t.id === activeThreadId;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
data-testid="thread-item"
|
||||
data-active={isActive}
|
||||
onClick={() => onSelectThread(t.id)}
|
||||
className={`w-full text-left rounded-lg px-3 py-2 mb-1 transition-colors ${
|
||||
isActive
|
||||
? "bg-indigo-50 text-indigo-700 font-medium"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="truncate text-sm leading-snug">{t.title}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
{new Date(t.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAgent } from "@copilotkit/react-core/v2";
|
||||
import { DEFAULT_THREAD_TITLE, titleFromText } from "@/lib/threads";
|
||||
|
||||
const AGENT_ID = "oracle_concierge";
|
||||
|
||||
type MinimalMessage = { role?: string; content?: unknown };
|
||||
|
||||
/** Text of the first user message in a transcript (handles string or parts). */
|
||||
function firstUserText(messages: MinimalMessage[]): string {
|
||||
const firstUser = messages.find((m) => m?.role === "user");
|
||||
const raw = firstUser?.content;
|
||||
if (typeof raw === "string") return raw;
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.map((p) =>
|
||||
typeof p === "string" ? p : ((p as { text?: string })?.text ?? ""),
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Names a thread after its first user message. Mounted (render-null) inside the
|
||||
* CopilotKit provider so it can read the active thread's transcript via useAgent.
|
||||
*
|
||||
* It titles ONLY when the shared agent is actually on the active thread
|
||||
* (`agent.threadId === activeThreadId`) and that thread still carries the default
|
||||
* title. This is the fix for "a new thread reuses the prior thread's name": the
|
||||
* previous implementation ran a useEffect keyed on `activeThreadId`, so on a thread
|
||||
* switch it read the shared, agentId-scoped `agent.messages` *before* CopilotChat's
|
||||
* connect cleared them — naming the fresh thread after the prior conversation.
|
||||
*
|
||||
* Instead we drive titling off the agent's own message/run events. Those fire on
|
||||
* `addMessage` (the user's submit, by which point `agent.threadId` is the active
|
||||
* thread) and on the switch-time `setMessages([])` (empty transcript → no title) —
|
||||
* never with another thread's transcript while `threadId` already points here.
|
||||
*/
|
||||
export function ThreadTitler({
|
||||
activeThreadId,
|
||||
activeTitle,
|
||||
onTitle,
|
||||
}: {
|
||||
activeThreadId: string;
|
||||
activeTitle: string;
|
||||
onTitle: (id: string, title: string) => void;
|
||||
}) {
|
||||
const { agent } = useAgent({ agentId: AGENT_ID });
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent) return;
|
||||
const tryTitle = () => {
|
||||
if (!activeThreadId || activeTitle !== DEFAULT_THREAD_TITLE) return;
|
||||
// Only title the thread the agent has actually switched to — guards against
|
||||
// reading the previous thread's still-loaded transcript during a switch.
|
||||
if (agent.threadId !== activeThreadId) return;
|
||||
const title = titleFromText(
|
||||
firstUserText((agent.messages ?? []) as MinimalMessage[]),
|
||||
);
|
||||
if (title) onTitle(activeThreadId, title);
|
||||
};
|
||||
const sub = agent.subscribe({
|
||||
onMessagesChanged: tryTitle,
|
||||
onRunStartedEvent: tryTitle,
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, [agent, activeThreadId, activeTitle, onTitle]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
export type Flight = {
|
||||
id: string;
|
||||
airline: string;
|
||||
flight_no: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
depart: string;
|
||||
arrive: string;
|
||||
duration: string;
|
||||
stops: number;
|
||||
cabin: string;
|
||||
price_usd: number;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const _cache = new Map<string, Flight>();
|
||||
|
||||
export function rememberFlights(list: Flight[]): void {
|
||||
for (const f of list) {
|
||||
if (f && typeof f === "object" && typeof f.id === "string") {
|
||||
_cache.set(f.id, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getFlight(id: string): Flight | undefined {
|
||||
return _cache.get(id);
|
||||
}
|
||||
|
||||
export function parseFlights(result: string): Flight[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(result);
|
||||
if (Array.isArray(parsed)) {
|
||||
const flights: Flight[] = parsed
|
||||
.filter(
|
||||
(el): el is Record<string, unknown> =>
|
||||
el != null &&
|
||||
typeof el === "object" &&
|
||||
typeof (el as Record<string, unknown>).id === "string",
|
||||
)
|
||||
.map((el) => ({
|
||||
...(el as Flight),
|
||||
price_usd:
|
||||
typeof el.price_usd === "number"
|
||||
? el.price_usd
|
||||
: Number(el.price_usd) || 0,
|
||||
}));
|
||||
rememberFlights(flights);
|
||||
return flights;
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTime(iso: string): string {
|
||||
if (!iso || typeof iso !== "string") return "—";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function stopsLabel(stops: number): string {
|
||||
if (stops === 0) return "Nonstop";
|
||||
if (stops === 1) return "1 stop";
|
||||
return `${stops} stops`;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type Thread = { id: string; title: string; createdAt: number };
|
||||
const STORAGE_KEY = "oracle-concierge-threads";
|
||||
|
||||
/** Title a thread carries until its first user message names it (see ThreadTitler.tsx). */
|
||||
export const DEFAULT_THREAD_TITLE = "New conversation";
|
||||
|
||||
const MAX_TITLE_LEN = 60;
|
||||
|
||||
/**
|
||||
* Derive a thread title from a user's submitted message text. Collapses
|
||||
* whitespace, trims, and truncates to MAX_TITLE_LEN with an ellipsis. Returns
|
||||
* null for empty/whitespace-only input (caller leaves the default title).
|
||||
*/
|
||||
export function titleFromText(text: string): string | null {
|
||||
const clean = text.replace(/\s+/g, " ").trim();
|
||||
if (!clean) return null;
|
||||
return clean.length > MAX_TITLE_LEN
|
||||
? `${clean.slice(0, MAX_TITLE_LEN).trimEnd()}…`
|
||||
: clean;
|
||||
}
|
||||
|
||||
function makeThread(title = DEFAULT_THREAD_TITLE): Thread {
|
||||
const id =
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `t-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
return { id, title, createdAt: Date.now() };
|
||||
}
|
||||
|
||||
export function useThreadStore(): {
|
||||
ready: boolean;
|
||||
threads: Thread[];
|
||||
activeThreadId: string;
|
||||
newThread: () => void;
|
||||
selectThread: (id: string) => void;
|
||||
renameThread: (id: string, title: string) => void;
|
||||
} {
|
||||
const [threads, setThreads] = useState<Thread[]>([]);
|
||||
const [activeThreadId, setActiveThreadId] = useState<string>("");
|
||||
const [ready, setReady] = useState<boolean>(false);
|
||||
|
||||
// Mount: hydrate from localStorage, seed if empty. We intentionally setState
|
||||
// synchronously here — localStorage is client-only, so reading it is deferred to
|
||||
// this mount effect (after the SSR-safe empty initial render) to avoid a
|
||||
// hydration mismatch. The single extra mount render is the expected hydration cost.
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- intentional client-only localStorage hydration on mount */
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
let valid: Thread[] = [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
valid = Array.isArray(parsed)
|
||||
? parsed.filter(
|
||||
(t): t is Thread =>
|
||||
t != null &&
|
||||
typeof (t as Thread).id === "string" &&
|
||||
typeof (t as Thread).title === "string" &&
|
||||
typeof (t as Thread).createdAt === "number",
|
||||
)
|
||||
: [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("thread store: failed to parse localStorage", e);
|
||||
}
|
||||
|
||||
if (valid.length > 0) {
|
||||
setThreads(valid);
|
||||
setActiveThreadId(valid[0].id);
|
||||
} else {
|
||||
const seed = makeThread();
|
||||
setThreads([seed]);
|
||||
setActiveThreadId(seed.id);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([seed]));
|
||||
} catch (e) {
|
||||
console.warn("thread store: failed to write seed to localStorage", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("thread store init failed", e);
|
||||
const seed = makeThread();
|
||||
setThreads([seed]);
|
||||
setActiveThreadId(seed.id);
|
||||
} finally {
|
||||
setReady(true);
|
||||
}
|
||||
}, []);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
// Persist whenever threads change (after ready)
|
||||
useEffect(() => {
|
||||
if (!ready || typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(threads));
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"thread store: failed to persist threads to localStorage",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}, [threads, ready]);
|
||||
|
||||
const newThread = useCallback(() => {
|
||||
const t = makeThread();
|
||||
setThreads((prev) => [t, ...prev]);
|
||||
setActiveThreadId(t.id);
|
||||
}, []);
|
||||
|
||||
const selectThread = useCallback(
|
||||
(id: string) => {
|
||||
if (threads.some((t) => t.id === id)) {
|
||||
setActiveThreadId(id);
|
||||
}
|
||||
},
|
||||
[threads],
|
||||
);
|
||||
|
||||
const renameThread = useCallback((id: string, title: string) => {
|
||||
setThreads((prev) =>
|
||||
prev.map((t) => (t.id === id && t.title !== title ? { ...t, title } : t)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
ready,
|
||||
threads,
|
||||
activeThreadId,
|
||||
newThread,
|
||||
selectThread,
|
||||
renameThread,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../agent" || exit 1
|
||||
uv run uvicorn concierge.server:app --reload --port "${PORT:-8000}"
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../agent" || exit 1
|
||||
uv sync
|
||||
Reference in New Issue
Block a user