chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -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: & < > ->
&amp; &lt; &gt;). We must reverse that before persisting, or Oracle Agent
Memory stores corrupted facts like "fares &lt; $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()
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
View File
@@ -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:
@@ -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"
}
}
@@ -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 &amp; 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"
>
&#9776;
</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