Skip to content

feat(002-web-tools): web search/fetch tools, suggestions, rebrand#2

Merged
FireTable merged 5 commits into
mainfrom
feat/002-web-tools
Jun 23, 2026
Merged

feat(002-web-tools): web search/fetch tools, suggestions, rebrand#2
FireTable merged 5 commits into
mainfrom
feat/002-web-tools

Conversation

@FireTable

Copy link
Copy Markdown
Owner

What's in this PR

Four commits on feat/002-web-tools:

  1. chore: centralize UI constants and rebrandAPP_NAME + DEFAULT_THREAD_TITLE in lib/constants.ts, plumbed through layout, thread list, threads schema/queries, tests, README.
  2. chore: add dev-server protection rule to CLAUDE.md — rule 6: never kill/restart a running dev server.
  3. feat(frontend): show opening suggestion on empty chat — single prompt via Suggestions([...]) + relaxed AuiIf to allow suggestions while composer has content.
  4. feat(backend): add web search and fetch tools via JinasearchWeb (Jina Search) and fetchUrl (Jina Reader) with in-memory rotating API key pool (lib/jina.ts) and 401/403 failover. No blanket tool interrupt — write tools added later will hang their own interrupt.

Tests

  • pnpm test 112/112 ✓
  • pnpm lint
  • pnpm exec tsc --noEmit

Notes for reviewer

  • JINA_API_KEYS is a comma-separated list in .env.local; the pool rotates per request and blacklists 401/403 keys.
  • Graph flow: agent → tools (loop) / afterAgent → END with renameThread parallel. No interruptBefore — tools run unconditionally; per-node interrupt can be added when write tools land.
  • The opening suggestion uses the runtime's suggestions adapter path; the useAui({...}) pattern was discussed and intentionally not used (the thread tree reads from the provider's inner aui, not from a parent useAui extension).

@FireTable FireTable merged commit cc73c69 into main Jun 23, 2026
@FireTable FireTable deleted the feat/002-web-tools branch June 23, 2026 08:39
FireTable added a commit that referenced this pull request Jul 2, 2026
Shared building blocks for US1/US2/US3: env-bounded constants (NFR-004), Zod validators for RFC 6902 patches + Summary closed-interval invariant, store wrappers around PostgresStore, and the profile-size guard with structured MemorySizeError (NFR-003). Tests reach 90%+ coverage on validators + queries per rule #2.
FireTable added a commit that referenced this pull request Jul 6, 2026
…ph runtime

* feat(backend): add PostgresStore for long-term memory

Mirror the existing PostgresSaver wiring: new backend/store.ts exports a PostgresStore instance built from DATABASE_URL, with setup() called once at module load. Wire it into the graph via compile({ store }) so nodes can reach runtime.store for cross-thread memory. No node consumes the store yet — that's the next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(003-langgraph-store): add feature spec for LangGraph long-term memory

9 docs covering one feature (PostgresStore-backed long-term memory): spec, plan, research, data-model, quickstart, tasks, and 3 contracts. Spec is in Draft status; implementation follows /speckit.implement.

* feat(memory): add foundational layer for long-term memory

Shared building blocks for US1/US2/US3: env-bounded constants (NFR-004), Zod validators for RFC 6902 patches + Summary closed-interval invariant, store wrappers around PostgresStore, and the profile-size guard with structured MemorySizeError (NFR-003). Tests reach 90%+ coverage on validators + queries per rule #2.

* feat(memory): add 4 API endpoints + Memory settings tab

US3 surface: GET /api/memory/profile (with session + social), DELETE /api/memory/profile/[key] (FR-014 path regex [A-Za-z0-9_-]{1,64}), GET /api/memory/threads (grouped by threadId, sequence desc, updatedAt desc), DELETE /api/memory/threads/[threadId] (collapse by namespace prefix). All four wrapped in withAuth per rule #9.

UI: MemoryView component (Profile rows with (from account)/(saved by you) hints, Delete only on store rows; Thread Summaries grouped by threadId with Delete all) at /settings/memory, surfaces in UserButton menu via the 'links' slot (better-auth-ui 1.6.27's settingsTabs API has types but no render site — 'links' is the lowest-friction surface).

Drizzle socialAccounts query (provider only, no accountId per FR-020) lives next to the other profile queries.

* feat(memory): save_memory tool + withMemoryRecall middleware

US1:

- saveMemoryTool at backend/tool/memory/save-memory-tool.ts. RFC 6902 patches (add/replace/remove only; move/copy/test rejected at zod level), pre-validates replace/remove against the current profile so a missing-path patch isn't silently no-op'd, fast-json-patch.apply with structuredClone (in-place mutation safety), then profile-size guard, then store write. Returns JSON { ok, bytes, keyCount }. Surface-level errors: MissingUserIdError.code === MISSING_USER_ID (FR-023 fail-fast), MemoryPatchError.code === PATCH_FAILED for path issues, MemorySizeError re-exported from backend/memory/profile-size.ts.

- withMemoryRecall at backend/middleware/with-memory-recall.ts. Proxy over the inner ChatOpenAI — only .invoke is intercepted; bindTools/stream/batch/withConfig forward transparently. On invoke, if config.configurable.userId is non-empty, prepend a single <memory>...</memory> system message with profile + session + socialAccounts + threads top-K. Pass-through unchanged when userId absent (FR-007).

- backend/model.ts: chatModel = withMemoryRecall(baseChatModel). chatModelWithoutThink stays un-wrapped because the rename node is a background task and shouldn't carry the recall payload.

- backend/tool/index.ts: ALL_TOOLS now includes saveMemoryTool.

- app/api/[..._path]/route.ts: POST/PUT/PATCH body rewrite injects config.configurable.userId from the auth session before forwarding to LangGraph. Without this, the SDK never carries userId into model.invoke, and FR-007 (pass-through) is exercised for every call.

* feat(memory): threadSummarize node wired after afterAgent

US2:

- backend/node/thread-summarize-node.ts: compute userMessageCount from state.messages, gate on THRESHOLD (skip when <= 10), find latest summary for this thread, derive startIdx = (latest.endMessageIndex ?? -1) + 1 / endIdx = userMessageCount - KEEP_RECENT, skip when endIdx < startIdx (FR-010 — closed-interval zero window). Take the closed window [startIdx, endIdx] via messages.slice(startIdx, endIdx + 1) (note +1 because JS slice is end-exclusive). Call chatModel.withStructuredOutput for { name, description } and writeSummary to the store with messageCount = endIdx - startIdx + 1 — both inclusive ends per FR-010.

- backend/agent.ts: add the node to both buildSubgraph and buildInlined, sitting after afterAgent and before END. Pure side-effect node, no messages channel writes, self-skips when conditions don't apply.

- backend/state.ts: no schema change — userMessageCount is derived in the node (one filter over messages), avoiding reducer plumbing through every model node return.

- tests/backend/agent-topologies.test.ts: add threadSummarize to both topology node lists.

Edge cases tested: 1-message window (startIdx===endIdx), 2-message window, endIdx<startIdx skip, no prior + first summary, incremental next run after a prior summary, missing userId / thread_id.

* test(memory): add US4 session + social integration tests

Verifies the withMemoryRecall system message contains session.name / session.email / socialAccounts[].provider, NEVER leaks accountId / accessToken / refreshToken (FR-020), and confirms session is read fresh on every invoke — no per-process caching (US4 step 3).

* docs(memory): document Memory API + save_memory tool

Add docs/APIS.md Memory section per rule #1 — GET/DELETE /api/memory/profile, GET/DELETE /api/memory/threads, plus the save_memory tool contract (RFC 6902 patches, FR-023 fail-fast, FR-001..003 invariants, FR-020 no separate forget tool). Mirrors contracts/memory-api.md + contracts/save-memory-tool.md.

* fix(memory): import fast-json-patch as default export

fast-json-patch@3.1.1 ships ESM as `export default Object.assign(core, duplex, {...})` — no named `applyPatch` export. The named import `{ applyPatch as applyJsonPatch }` works under vitest (CJS transform) but throws SyntaxError under Node ESM at runtime, blocking langgraph dev startup.

Default-import + destructure matches the package's actual ESM shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(memory): swap to immutable-json-patch + allow optional userMessageCount

Two changes from one save_memory audit:

1. fast-json-patch@3.1.1 has a broken ESM/tsx loading chain — its
   `index.ts` imports `./src/core` which doesn't resolve under tsx, and
   the package's `index.mjs` only exports a default object (no named
   `applyPatch`). Switch to immutable-json-patch@6.0.3, which is pure
   ESM (`type: "module"`) with a named `immutableJSONPatch` export.
   Returns a fresh object, so the explicit `structuredClone` is gone.

2. Tests construct fixtures with the derived `userMessageCount`
   alongside `messages` for readability — the node derives it itself
   but the type didn't permit the extra field, producing TS2353 errors
   that vitest silently ignored. Make it optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(deps): add bowser for better-auth-ui Settings

`npx shadcn add https://better-auth-ui.com/r/settings.json` pulls in
`bowser` (used by the shadcn-installed settings components for
capability detection). Keep the dep alongside the rest of the
better-auth-ui stack rather than hiding it behind the UI primitives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(settings): center layout + redirect /settings → /settings/account

Two UX fixes for the better-auth-ui <Settings> surface:

1. Wrap the rendered Settings in a max-w-3xl mx-auto container with
   a "Settings" h1 + py-8/12 padding — the shadcn Tabs + Cards stretched
   edge-to-edge without it and looked unfinished on wide viewports.
   TabsList/TabsContent inherit the container width via flex parent.

2. Add app/settings/page.tsx that redirects to /settings/account. The
   better-auth-ui UserButton default Settings link is `${basePaths.settings}`
   which lands on /settings itself; <Settings> then throws on the empty
   path. Redirect avoids the 500 and keeps UserButton's link shape working
   without changing better-auth-ui's href construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(settings): rename Profile → About you + filter credential accounts

Two small fixes after a closer look at the Memory tab:

1. "Profile" → "About you". Closer to mainstream agent UX (Claude.ai
   uses "What Claude knows about you"; ChatGPT uses "User memory").
   "Profile" is overloaded with the Account tab.

2. `getSocialAccounts` was returning every row in `account`, including
   better-auth's `providerId="credential"` row (the email+password
   account). Showing that as a "linked account" alongside GitHub/Google
   was misleading — it's not a social login. Filter it out at the query
   layer so the API contract matches its name. Capitalise the label
   (`github` → `GitHub`) while we're here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(auth): set session.freshAge=0 + HMR-friendly config reload

The Security tab's "Active sessions" card hit GET /api/auth/list-sessions
with 403 SESSION_NOT_FRESH. better-auth's freshSessionMiddleware rejects
sessions older than `freshAge` (default 24h) on sensitive-session
endpoints. Dev fixtures keep sessions for weeks and trip the check.

Two changes:

1. `session.freshAge: 0` disables the fresh check. Session expiry (7d)
   still bounds exposure; the fresh check is mainly a CSRF-rotation
   guard, not a primary security control.

2. Drop the cached `globalThis.__auth` instance on every module reload
   in dev so config edits (freshAge, plugins, etc.) take effect
   without a server restart. The instance is cheap to recreate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(memory): save_memory schema + recall wrapper + per-key thread delete

Three live-tested bugs in the memory feature, all fixed:

- save_memory tool's zod schema wrapped SaveMemoryInputSchema in
  z.preprocess + z.any(), which collapsed the JSON-Schema view the
  LLM sees to {type:'any'}. The agent retried four times guessing
  array vs object vs envelope shapes before giving up. Pass the
  envelope directly and add a worked example to the description.
  Verified live: second message stores Name + City in one call.

- withMemoryRecall's Proxy only intercepted .invoke, but every chat
  call site does chatModel.bindTools(ALL_TOOLS).invoke(...). The
  bindTools result is a fresh BaseChatModel that bypassed the
  outer Proxy, so no <memory> system message was ever injected.
  Re-wrap bindTools() in the Proxy. Plus a real-bug fix: every
  model node in backend/agent.ts and backend/agent/chat-agent.ts
  was dropping the second arg of the node signature, so the
  RunnableConfig (with userId) never reached model.invoke. Both
  the bindTools re-wrap and the config plumbing are required —
  a /tmp trace shows 6 invoke calls with userId=<none> before,
  userId=FHK... after. Verified live: a fresh thread answers
  "you live in San Francisco" from the persisted profile.

- deleteThreadSummaries used store.batch([{op:'delete',...}]),
  but langgraph-checkpoint-postgres 1.0.4 batch() has no delete
  branch — it throws "Unsupported operation type" inside its
  loop. The API returned deletedCount from the pre-batch filter
  anyway, so the rows stayed. Loop store.delete(namespace, key)
  per row to match what the upstream library was written for.

* fix(observability): show LLM prompts in span details card

The LLM Prompts section never rendered because the field's show() checked Array.isArray(s.input), but the callback handler stores input as { prompts: string[] } (always wrapped). Reach through the wrapper and use bare:true so the section title is the only label. Also capitalize role summaries and tighten the inner padding while in the area.

* feat(memory): migrate to LangGraph PostgresStore with merged profile/auth view

Replace the with-memory-recall Proxy on chatModel with an explicit
buildSystemMessageWithMemory helper that injects a <memory> block into
every sub-agent's SystemMessage, sourced from LangGraph PostgresStore.

Store the user's profile separately from the live auth overlay (name,
email, image, socials) so the model can patch fields it originally saw
from OAuth without overwriting them. save_memory now validates patches
against the merged view and writes back only divergent fields —
authenticated identities stay in the auth layer.

Drop the redundant call-model-node, with-memory-recall middleware, and
getCodeTools aggregator. Aggregate CODE_TOOLS inline in tool/index.ts
alongside the other conditional tool registrations. Extract a shared
CopyButton from the observability panel for reuse.

* feat(frontend): extract BrandMark + add back-to-chat link on settings page

Move the icon + APP_NAME row out of app/assistant.tsx into a shared
BrandMark component, and add BrandMarkLink (anchored to /chat) at the
top of the settings page so the user has an obvious back-to-chat
affordance — settings has no sidebar of its own. Brand sits at the
viewport's left edge (px-6) to match the chat sidebar's x-position.

* feat(memory): SaveMemoryCard shows before/after diff for save_memory

The save_memory tool now returns the pre/post doc plus a normalized patch list (each entry carries oldValue captured against a running copy, so chained replace→remove reports the post-replace value). A new SaveMemoryCard under components/tool-ui/memory renders Added/Removed/Changed rows with a pill-style field name, registered via memoryToolkit in components/tool-ui/toolkit.tsx. Shared prettifyKey lives in lib/memory/format so the card and settings/memory view render underscore-keys identically (Travel Preferences, Morning Routine). SuccessBanner default check icon now uses emerald to match the resolved-state semantics on ask-location.

* refactor(memory): render nested values as pretty JSON block

The bespoke key/value tree renderer in settings/memory left large gaps when values were short ("fast", "winter"), and short arrays of small objects wasted rows. Replace the tree walker with a single <pre>{JSON.stringify(raw, null, 2)}</pre> so the user sees the same shape the save_memory card shows in its CHANGED row. Primitive leaves still render inline so single-value rows like "Role: engineering manager" stay compact. Drop the now-unused renderChild / serializeNode helpers and the MAX_DEPTH clamp; JSON.stringify handles arbitrary depth without the runtime shape leaking through.

* feat(memory): cap JSON block height + add copy button

memory-view's pretty-printed JSON block grew unbounded as profile fields accumulated — a deep travel_preferences payload could push the rest of the card off-screen. Cap height at 240px (same default the observability panel uses) and overflow-scroll inside the block. Add a CopyButton in the top-right corner so the user can grab the raw JSON without opening devtools — mirrors the affordance observability/panel.tsx already exposes per span field.

* refactor(backend): move thread-summarize prompt to system.ts and feed human+ai transcript

threadSummarizeNode was inlining its prompt string and feeding chatModel.invoke a single concatenated string — the LLM saw only the user side of the conversation. Move the prompt to backend/prompt/system.ts alongside the other agent prompts (CHAT_AGENT_PROMPT, WEATHER_AGENT_PROMPT, etc.) so the prompt-authoring convention stays in one file, then send [SystemMessage, HumanMessage(transcript)] so the model sees both the user turns and the assistant replies for that window. Window math (startIdx/endIdx keyed off user-message count, writeSummary.startMessageIndex/endMessageIndex/messageCount) is unchanged.

* refactor(backend): route threadSummarize via conditional edge

threadSummarize ran unconditionally after every turn, doing five early-return checks inside the node to decide whether to call the LLM and write to the store. That meant every turn produced a threadSummarize span in observability even when there was nothing to summarize. Move the cheap necessary-condition check (userMessageCount > THRESHOLD + KEEP_RECENT) into a conditional-edge router that routes around the node on the trivial path. The node still owns the store-dependent close-window check (endIdx < startIdx) so the single source of truth for 'is there work to do once I'm in here?' stays inside the node. Both the subgraph and inlined builders now use addConditionalEdges(afterAgent, shouldSummarizeRouter, { threadSummarize, __end__: END }).

* style(backend): rename threadSummarize window slice to excerpt

The local variable held the human+ai transcript slice the LLM was fed;
calling it 'window' invited confusion with the browser Window API and
shadowed a meaningful math term. Rename to 'excerpt' — matches the
language the THREAD_SUMMARIZE_PROMPT already uses ('the following
conversation excerpt') so the prompt + code + tests read as one
continuous vocabulary.

chore(frontend): switch langgraph stream to multitaskStrategy interrupt

The default 'reject' strategy throws HTTP 422 when the user submits a
new message while the previous run is still streaming — visible as a
Network Error toast on every Regenerate / Send-while-generating.
'Interrupt' cancels the in-flight run and lets the new one take over,
which is what the chat UX actually wants.

* fix(backend): use LangGraph END symbol in conditional-edge routers

Both shouldSummarizeRouter and shouldRenameRouter were returning the literal string "__end__" instead of LangGraph's END sentinel. LangGraph's addConditionalEdges compares the router's return value against the pathMap keys via ===, so a string "__end__" never matched the END symbol and the runner fell off the graph → "Branch condition returned unknown or null destination". The pathMap itself used the correct { __end__: END } shape, but the router side was string-typed. Switch both router return types and bodies to END and update the tests to assert against the END symbol.

* fix(backend): return node name from shouldSummarizeRouter + colocate with other routers

Two bugs in one:
1. shouldSummarizeRouter was returning the intermediate string
   "summarize", but the pathMap registered threadSummarize under the
   key "threadSummarize" — the comparison missed, so the runner fell
   off the graph with "Branch condition returned unknown or null
   destination". Return the registered node name directly.
2. shouldSummarizeRouter lived in backend/node/thread-summarize-node.ts
   alongside its target node, but every other router in this repo
   (weatherRoute, chatRoute, cryptoRoute, codeRoute, shouldRenameRouter)
   sits in backend/agent.ts next to the graph it routes. Move it back
   so the routers live in one file and the node stays focused on the
   work it actually does once entered.

* style(backend): pass nostream tag as invoke option, drop redundant cast

The { tags: ["nostream"] } option was sitting outside the invoke()
call as a stray comma-expression — invalid syntax flagged by oxlint.
It is the second positional argument to BaseChatModel.invoke(), so
move it inside the call. withStructuredOutput already returns
z.infer<typeof schema>, so the explicit cast is now redundant —
drop it.

* refactor(observability): unify prompts + output into Messages section

The LLM span used to render only input.prompts under the
Prompts section, leaving the model's returned AIMessage(s) hidden
inside the raw JSON blob on the right. Operators debugging a bad
reply had to expand the JSON tree to see what the model actually
said.

Three changes collapse the two views into one:

- Rename the LLM section Prompts -> Messages so it can carry
  both directions.
- Extract parsePromptGroup + readOutputMessages + buildLlmMessages
  helpers next to readStructuredOutput. Input still keys off the
  role-prefixed line serialization LangChain uses; output walks
  output.generations[*][*].message and falls back to tool_calls
  for content-empty AIMessages.
- New entries (the model's reply) render with a solid primary
  chip so they read at a glance vs the input prompt's muted
  background.

Entries default collapsed and use the native <details> triangle.
The flex layout for role+chip is on an inner wrapper instead of
<summary> itself so the browser's default list-item marker still
renders.

* feat(observability): mark last human input as New on LLM span

Reading the prior list the operator only saw one [NEW] chip, on
the model's reply. The user message that triggered the call was
already inside the prompt history and got muted out, so the
"this turn" pair (user → assistant) didn't read together.

* feat(observability): right-align New chip + scope New to current turn

Two follow-ups on the Messages section:

1. The [NEW] chip sat inline next to the role label. Operators
   reading a turn wanted the chip pushed to the row's right end
   so the row read as '<role> ............ [NEW]'. Position it
   absolutely (top: 1/2, right: 1.5) and reserve the slot with
   pr-20 on summary so role text never overlaps the chip.

2. A turn that goes Human -> Ai(tool_call) -> Tool -> Ai has the
   middle two messages land between the trigger user prompt and
   the model's reply. They were treated as conversation history
   and not flagged, which read as 'this is just prior context'.
   Mark them New too — anything from the most recent Human
   message forward is this LLM call's contribution to the turn.

Layout note: the chip is absolute-positioned so summary can keep
its default list-item display — the browser's native disclosure
marker (and its open/close rotation) survive. Earlier attempts
to use flex list-none + a manual glyph swapped rotation for a
static triangle, which is what we just un-did.

* feat(backend): offload thread summarize to background_agent graph

Move last_message_at touch + threadSummarizeNode out of the chat graph's afterAgent into a registered background_agent graph (./backend/background-agent.ts:graph). Chat graph's terminal node scheduleBackground dispatches via SDK runs.create (default) or in-process invoke (INVOKE_BACKGROUND_AGENT=1) — both paths reach the same shared CapturingHandler singleton from backend/callbacks.ts so spans land in the same observability row set.

threadSummarizeNode rewritten to batch human turns in fixed-size windows (BATCH_SIZE, default 6) above KEEP_RECENT and replace them inline in the messages channel. Cross-thread summary injection in the system prompt retired (recall.ts/template.ts no longer pull thread summaries); per-thread summaries now live inline in the messages channel. SummaryEntry schema gains messageIds + summary fields, drops name/description, and renames updatedAt to createdAt. MemoryView renders batch summaries with sequence, message range, and createdAt timestamp.

* wip: snapshot before USE_SUBGRAPH sendCommand investigation

Temporary commit. Working tree mixes:
- subgraph messages persistence helper (lib/langgraph/merge-subgraph-messages.ts + tests + app/assistant.tsx load)
- USE_SUBGRAPH=true interrupt resume payload switch to { [id]: json } (ask-location-card.tsx) — debug only, not yet verified
- langGraphClient singleton in scheduleBackground-node.ts
- misc: env defaults, memory template, thread InterruptUI comment

ask-location-card sendCommand on USE_SUBGRAPH=true still does not resume the run; investigation continues next session. Pre-commit hooks skipped (pre-existing memory + interrupt-ui tests still red).

* fix(observability): order interrupt span after tool call

The interrupt event lands on the CapturingHandler's chainEnd synchronously
with the tool-call chainEnd, so the 1-tick timing race can render the
human-input span above the tool-call span in the waterfall (parent sort
key = started_at). +100ms on the interrupt started_at guarantees the
ordering without depending on the event loop's microtask order.

* fix(frontend): stream subgraph events and stabilize ask_location resume

USE_SUBGRAPH=true was broken at the ask_location card: the interrupt
raised inside the weatherAgent subgraph was never reaching the client
because langgraph-api@1.4.1's api/runs.mjs:85 gates subgraph event
forwarding on 'run.stream_subgraphs ?? false' (default false). With
streamSubgraphs: true the namespaced 'updates|<ns>' event carrying
__interrupt__ now flows, useLangGraphInterruptState populates, and the
card's useLangGraphSendCommand({ resume }) reaches the suspended task.

The server's __pregel_resume_map[ns] routes the resume payload to the
correct subgraph task automatically — client just sends bare JSON, no
namespace routing needed. That makes the keyed '{ [id]: json }' form
the card used to send redundant; reverting to a plain JSON string
removes the InterruptState read + the SDK cast workaround.

onDisconnect: 'continue' is paired with the change so the stream
stays alive across the interrupt and the resume lands on the same
run instead of a fresh one.

unstable_allowCancellation + unstable_enableMessageQueue in app/assistant
are the runtime flags that make the composer Cancel button work and
let queued messages land in the next run; both pair with the
'continue' disconnect policy.

* chore(deps): bump @assistant-ui/core 0.2.19 → 0.2.20

Transitive bump pulled in by an earlier 'pnpm install --force' during
the react-langgraph patch work. No package.json change — just the
lockfile re-resolving a newer core that satisfies the existing peer
range.

* refactor(frontend): drop InterruptUI — tool-call card handles it

USE_SUBGRAPH=true makes the toolkit renderer's own useLangGraphInterruptState
work directly, so the global InterruptUI in thread.tsx was rendering a
duplicate / dead path. The inlined-mode branch (USE_SUBGRAPH=false) was
already broken (see CLAUDE.md — the EventStreamCallbackHandler run-map
bug); keeping InterruptUI 'just in case' served no real consumer.

Drop InterruptUI, its test, the now-unused toolkit/InterruptState hooks
imports, the per-message isLast gate, and the !interrupt WorkingIndicator
guard. The tool-call card's addResult path is the single source of truth
for the ask_location (and any other interrupt-driven tool) UI.

* refactor(backend): drop USE_SUBGRAPH=false inlined topology

The compiled-subgraph path is now the only path; `USE_SUBGRAPH` and `NEXT_PUBLIC_USE_SUBGRAPH` env toggles, the `buildInlined()` builder, and the inlined model/tool node wrappers in `backend/agent.ts` are gone. `backend/background-agent.ts` drops the pointless `summarizeNode` wrapper and calls `threadSummarizeNode` directly. Docs (CLAUDE.md, docs/INTERRUPT.md, docs/OBSERVABILITY.md) collapse the dual-mode sections; tool-ui card comments and create-stream.ts / callback-collector.ts comments reword the parent_run_id quirk without referencing the removed toggle. Memory: langgraph-subgraph-run-map-bug.md updated to reflect that the workaround was retired after `streamSubgraphs: true` + `__pregel_resume_map[ns]` were confirmed working for the ask_location interrupt + Command(resume) flow. Specs (002, 003) left as historical snapshots — they describe decisions at the time.

Tests: removes 2 obsolete inlined-topology tests + 2 redundant summarizeNode-wrapper tests; same 7 pre-existing failures (schedule-background-node, memory/{recall,template}), unrelated to this change.

* fix(observability): nest subgraph steps under wrapper + close waiting chain on resume

Two bugs surfaced after USE_SUBGRAPH=true shipped:

1. Inner-subgraph steps (model / tools / subgraph __start__) landed at parent='root' instead of nesting under the weatherAgent wrapper. Cause: transform.ts parentIdFor used 'candidate.step >= s.step' to skip wrappers, but under compiled subgraphs the wrapper chain has langgraph_step HIGHER than its inner steps (outer RunnableSequence fires after the inner CompiledStateGraph ends). When repRaw's parent_span_id resolved to a span in the SAME step (e.g. ChatOpenAI → model RunnableSequence), the fallback couldn't find weatherAgent and dropped to root. Fix: pick the wrapper by longest-ns-strict-prefix of s.ns, not by step number.

2. handleChainError now flips wrapper chains to status='waiting' on GraphInterrupt (matching the synthetic human span). Without backfill they'd stay waiting forever — the chain's eventual handleChainEnd is a no-op because end() skips waiting→completed to protect the synthetic human span. Fix: extend backfillWaitingInterruptSpans to also close kind='chain' / status='waiting' wrappers when a resume tool arrives. Chain wrappers don't carry meta.interrupt_tool, so the second UPDATE closes them per-thread unconditionally (at most one active interrupt per thread in practice).

Tests: +3 (transform parentIdFor regression guard, backfill chain wrapper close, backfill cross-thread isolation); updated 1 (chain wrapper status expected 'waiting' not 'completed'). 56/56 observability tests pass; same 7 pre-existing failures in memory/{recall,template} + schedule-background-node, unrelated.

* refactor(backend): inline builder at top level — drop buildSubgraph wrapper

The 'function buildSubgraph() { return new StateGraph(...).addNode(...).addEdge(...) }' wrapper buys nothing — it gets called once at module load, has no callers from app code (only the smoke test), and just adds an extra frame around a literal. Inline it as 'export const builder = ...' and drop the wrapper.

The smoke test in tests/backend/agent-topologies.test.ts updates the import + describe block name accordingly. The test still asserts the structural shape (the compiled subgraphs wired as opaque nodes) and that compile() doesn't throw — no coverage loss.

* docs(memory): tell save_memory not to duplicate under similar keys

Real-world bug: the model was saving the same fact under 'primaryEmail' AND 'email' (or 'phone' + 'mobile'). Add a CONSTRAINT to the system prompt so each fact lands under one canonical key. Single-line addition, no other change.

* fix(observability): don't backfill chain wrappers — only human spans

User feedback: chain wrapper's status='waiting' shouldn't flip to 'completed' on tool arrival — the chain might still be processing the resume payload when the tool starts. The backfill I added in the previous commit was eager and visually meaningless: transform.ts renders the step as 'completed' via bucket.ended (the raw span status field doesn't drive the panel's step display), so leaving the wrapper at status='waiting' renders identically without overstating progress.

Revert: backfillWaitingInterruptSpans no longer touches kind='chain' rows. The synthetic human span (kind='human', inserted by handleToolError) still gets backfilled on resume — that one has no chain end of its own, so the backfill is the only way to close it. Tests: updated the chain-wrapper test to assert status stays 'waiting' (transform still renders it correctly); removed the handleChainEnd-flips test (handleChainEnd won't fire for the interrupted wrapper, since the chain ended via handleChainError).

* feat(observability): backfill waiting chain wrappers via __end__ signal

When a chain wrapper with output.output="__end__" lands for a thread,
the LangGraph branch has finished — flip any waiting chain wrapper at the
parent ns to status="completed" with ended_at = the __end__ wrapper's
ended_at. The prior turn's wrappers never receive their own handleChainEnd
(handleChainError already finalized them when GraphInterrupt bubbled up),
so the DB stays at status="waiting" / ended_at=null forever without this
trigger. handleChainError now writes ended_at=null (not Date.now()) so the
backfill is the only writer of the final timestamp.

* fix(panel): rotate chevron on collapse toggle

data-[collapsed=true]:-rotate-90 was on the SVG inside the
CollapseToggle button, but SpanPrimitive.CollapseToggle sets
data-collapsed on the BUTTON — the SVG never sees the attribute, so the
chevron never flips. Move the rotation class onto the button itself
(the only visible content is the SVG, so it rotates with the button).

* feat(backend): inherit parent checkpointer in sub-agent subgraphs

Sub-agent StateGraphs compiled with checkpointer: true so the
subgraph pulls the parent's PostgresSaver instead of starting fresh.
Required for sub-agent interrupts to share the thread's checkpoint
namespace and resume cleanly across the parent boundary.

checkpointer: true is invalid on root graphs (langgraph throws); it
only applies to subgraph.compile() when the parent already wired a
checkpointer.

* refactor(observability): wire panel root chain resolution

collectRootChains now keys roots by meta.run_id (invariant: outermost chain's span_id === meta.run_id) and dedupes via Map<run_id, RootChain> so two invokes sharing compile name don't collapse. rootForStep returns null instead of falling back to rootChains[0]; orphan steps are dropped rather than parked under a synthetic 'graph.invoke' root. Also fixes handleChainStart param order in CapturingHandler (runType was parentRunId, parentRunId was being misread), renames scheduleBackground to triggerBackgroundAgentNode, passes memoryJson in the prompt template, and comments out per-subgraph checkpointer. oxfmt pass on touched-by-pre-commit-hook files.

* test: align two tests with current handler behavior

callback-collector.test.ts: chain-wrapper's ended_at stays null through an interrupt (only the synthetic human span alongside the tool gets stamped); markRunningAsFailed reconciles it on restart when the resume fires handleChainEnd. trigger-background-agent-node.test.ts: drop afterSeconds: 3 from the runs.create payload expectation — the dispatch goes out immediately, no delay.

* fix(observability): persist chain at Start + dedupe roots by parent

- CapturingHandler.handleChainStart now calls persistSpan after
  start(), so the outer RunnableSequence row lands in DB before
  handleChainEnd fires. Closes the in-flight visibility gap where
  bg invokes never showed up in the panel because End hadn't fired.
  Inner spans (LLM/tool/retriever) still persist on End only —
  Start-persisting every leaf triples DB writes for no panel gain.
- collectRootChains now skips chains whose parent_span_id is null
  (langgraph-api's outermost RunnableSequence plumbing) and treats
  the inner CompiledStateGraph as the root. Map keyed by
  parent_span_id, so two invokes with different outermost parents
  stay distinct.

End-only persist was the original design — End stamps output / usage /
error / status, ON CONFLICT DO NOTHING dedupes the End overwrite. The
interrupt branch in handleChainError still fires persistSpan early so
waiting chains surface. The chain Start persist is the symmetric
counterpart for the in-flight case.

The transform fixture change is staged separately — the new filter
shape (parent must exist, dedupe by parent) requires fixtures to
model an outermost + inner wrapper pair per invoke. Failing tests:
8 in tests/lib/observability/transform.test.ts.

* feat(observability): surface in-flight bg runs to the panel

The bg agent dispatched via runs.create has no persisted CapturedSpan until its first Start callback fires — the panel otherwise drops the row from the waterfall silently. Stamping metadata.parent_message_id on every bg runs.create lets the observability per-turn GET filter langGraphClient.runs.list(threadId) to the current turn; the response now carries in_flight_runs alongside spans. The panel renders a synthetic skeleton row at the tail of the waterfall when in_flight_runs is non-empty and the sheet polls every 10s (RefreshCountdown owns the 1s/10s ticks so the panel body stays untouched) until the bg agent finishes. Shared lastHumanMessageId helper replaces the inline envelope walk in callback-collector and triggerBackgroundAgentNode. triggerBackgroundAgentNode now awaits dispatchViaCreate so SDK rejections hit the catch instead of leaking as unhandled rejections.

* feat(observability): panel + sheet show in-flight bg runs

Synthetic RunningSkeletonRow at the tail of the waterfall when in_flight_runs is non-empty; click-toggle selection on rows; RefreshCountdown component owns the polling + countdown lifecycle in one place so the parent sheet doesn't re-render every second. APIS.md documents the new in_flight_runs shape; tests cover the API route and the callback-collector parent_message_id stamping.

* refactor(observability): drop lodash-es pick, use destructuring in in-flight fetch

* feat(observability): server-side transform + per-turn detail endpoint

Push CapturedSpan → SpanData transform + aggregate into the route
handlers (lib/observability/transform.ts, aggregate.ts). Per-turn
detail endpoint at [parentMessageId]/spans/[spanId] with SDK fallback
filtered by metadata.parent_message_id. Panel lazy-fetches detail on
click with a glassy refresh overlay; restore hover tooltips, deselect
toggle, filled-triangle disclosure, animated message expansion, and
model name on LLM leaves. parentMessageId stamped onto every SpanData
so the panel can build the per-turn detail URL without re-walking the
waterfall tree. Docs (OBSERVABILITY.md / README.md / CLAUDE.md /
APIS.md) updated to match the new shape.

Also:
- Rename graph node "triggerBackgroundAgentNode" → "triggerBackgroundAgent"
  (string id only; the imported function name is unchanged). The Node-suffix
  was a leak from the function-call layer; the graph topology reads cleaner
  without it. agent-topologies.test.ts updated to match.
- Fix 8 pre-existing failures in transform.test.ts by giving root chain
  spans a non-null parent_span_id — single-root tests use "" so the
  parent's display name still falls through to span.name="chain"; the
  two multi-root tests use distinct placeholders ("pregel" / "pregel-bg"
  and "pregel-A" / "pregel-B") and have their name assertions updated
  to match. The fixtures assumed parent_span_id=null would pass
  collectRootChains; the production filter requires a non-null parent for
  any span meant to anchor a real (non-orphan) tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(memory): invalidate cache on profile key delete

DELETE /api/memory/profile/[key] now calls invalidateMemory(user.id)
after a successful delete, mirroring save-memory-tool's write-side
invalidation. All three mutation paths (save_memory tool, profile PUT,
profile DELETE) now keep the LRU cache honest — no 60s window where
the next chat turn reads a stale doc.

Also tighten the conflict-resolution example to "ask_location_cache /
ask_location_result" so the prompt doesn't name a field the tool
explicitly should not store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(memory): thread-summarize with store-anchored trigger and JSONL transcript

Replace the stateless THRESHOLD/KEEP_RECENT formula with a single KEEP_RECENT knob and a store-anchored trigger: read lastCompressedEndIdx from the persisted SummaryEntry list, re-emit missing chunks on user-driven delete, and write fixed K-sized windows amortized across K turns.

Switch the LLM-facing transcript from "User:/Assistant:" prose to JSONL (one line per human turn, tool_calls first-class). Role labels in plain text collided with ":" in user content, and trailing "[tool_call X]" prose was getting dropped by the model — JSONL iterates tool_calls naturally and is structurally aligned with the LLM's structured OUTPUT refs.

Memory tab frontend groups the flat API list by threadId using Map insertion order — strict passthrough of the backend's createdAt-asc order on both outer and inner levels.

* docs(backend): correct computeCumulativeWindow comment to match round-down behavior

The previous comment claimed the window length was 'fixed at KEEP_RECENT' but the implementation rounds down to the largest K-multiple of uncompressedCount. Update the comment with the actual math, examples, and rationale (round-down amortizes the LLM call cost better than fixed K — each store write covers the maximum summarizable window that fits in one prompt, fewer total passes than fixed-K). No code change.

* feat(memory): store thread summaries as structured entries

Replace SummaryEntry.summary: string with summary: { entries: [{ question, answer, refs }] } — the same shape the LLM produces via withStructuredOutput(summaryOutputSchema). Storage is the verbatim LLM output (no pre-formatted text), so later passes can compare / merge / dedupe across re-runs; the prior string shape lost the structure and produced near-duplicate summaries for the same Q&A.

formatSummaryText moves to lib/langgraph/format-summary.ts and is shared by:
  - chat-agent: passes the structured entries through to the <threads> prompt block (model reads the JSON dump).
  - Memory tab UI: calls formatSummaryText(s.summary.entries) at display time.

Strict schema — old string-typed summaries in the store fail SummaryEntrySchema.safeParse and are silently dropped by getAllUserSummaries. Run pnpm db:reset (or equivalent) to clear stale data, or re-trigger compression on those threads.

Verified end-to-end: seeded a structured summary to the test-obs account via scripts/seed-summary.ts; the dev server's /api/memory/threads and /api/memory/profile return the structured form; the Memory tab UI renders the formatted text identically to the prior string-typed output.

* feat(memory): thread summaries title + UI polish

Two coupled changes in one commit:

- API: GET /api/memory/profile now returns {store, auth, threads[]} with
  threadTitle (joined from the threads table) per summary entry. Wire
  shape stays flat; UI groups client-side by threadId.

- UI: thread header renders threadTitle (raw id as muted meta fallback);
  delete dialogs get a spinner + disabled state during the in-flight
  DELETE; loading state replaced with a skeleton mirroring the real
  layout; description paragraph under Thread summaries; ScrollText icon
  on thread rows.

* fix(memory): neutral summary voice + trailing AI + empty states

Three coupled memory polishing changes:

- Slice: extend the excerpt past humanIndices[endIdx] to the next human
  (or to messages.length if endIdx is the last) so the trailing user
  question captures its assistant reply. The previous slice stopped at
  the user message itself, leaving the last JSONL entry as a Q with
  no A. Locked in by a new test case.

- Prompt: rework THREAD_SUMMARIZE_PROMPT to a third-party observer
  voice — Q is the topic, A is the substance. Drop meta-verb narration
  in the few-shot example and in INPUT's tool_calls description; ban
  first/second-person pronouns and process-wrapping verbs (提供了 /
  请求了 / 已回应 / ...).

- UI: replace the two "No X yet." one-liners with icon-circle + title
  + helper paragraph empty states inside Card chrome.

* fix(memory): summary trigger at K, prompt Q as user question

- Node gate: drop `<=` to `<` so the first trigger fires at
  humanCount == K (was K+1). Round-down writes the same [0..K-1]
  window either way — the K+1 wait stranded users who never sent
  another message. Update the inline-pattern comment that was
  still describing the old K+1 timing. The "below threshold" test
  now uses K-1 humans and a new test locks in the K trigger.

- THREAD_SUMMARIZE_PROMPT: OUTPUT schema `question` field hint
  changes from `<topic of one chunk>` to `<the user question>`
  so the model writes Q as a Q, not as a section title.

* feat(backend): share thread summaries + trim at invoke across all agents

Router classifies from the last HumanMessage only — full history was a token-cost move for a yes/no classifier and could route off a stale topic.

trimMessagesForInvoke drops turns covered by thread summaries from the LLM input array; applied to chat / weather / crypto / code. state.messages is untouched (UI + checkpointer read it directly). Older turns surface via <earlier_conversation> in the SystemMessage, rendered with formatSummaryText so the model sees the same prose the Memory tab shows.

loadThreadSummariesForPrompt lifts out of chat-agent into template.ts so all four sub-agents share the same store-read path. Welcome-screen sample prompt tweaked to request test cases alongside the Two Sum snippet.

* feat(memory): collapse thread summaries by default with animated expand

Per-thread summary blocks were always expanded, which made a long thread list scannable only by scrolling. Defaulting each row to collapsed and surfacing a chevron toggle gives the Memory tab the same shape as a chat list: one header per thread, expand on demand.

Two parts:

- State: rename the Set from 'collapsed' (default-open) to 'expandedThreads' (default-closed) so absent ids == collapsed. A re-click removes the id and the row returns to collapsed with no extra ceremony.
- Motion: wrap each row in Radix Collapsible so the body height interpolates from 0 to auto via the --radix-collapsible-content-height variable. New animate-collapsible-down / animate-collapsible-up keyframes in globals.css read that variable and run for 200ms ease-out. The trigger owns aria-expanded/aria-controls and data-state lands on the body for CSS hooks; aria-label flips between 'Expand <title>' and 'Collapse <title>' so the screen reader announces the next-click action.

Lifted groupThreadsByThreadId out of the component so the grouping + recency sort + null-title branches get pure-function coverage in tests/frontend/settings/memory-view-group-threads.test.ts. Tests target the toggle by data-hint='thread-collapse' and the body by data-slot='thread-body' — both stable against aria-label churn and class changes.

* docs(memory): add MEMORY.md and update CLAUDE/README/TOOLS for this branch

The dual-graph runtime (mainAgent + background_agent), the save_memory tool, the threadSummarizeNode trigger, and the Memory settings tab all landed in this branch — the existing docs only described the pre-dual-graph state (a single agent graph + afterAgentNode, no memory tab, no save_memory tool in TOOLS.md). This commit catches the docs up without touching source code.

docs/MEMORY.md (new) — full design doc in the OBSERVABILITY.md / TOOLS.md / INTERRUPT.md style. Covers:
- Dual-graph topology (mainAgent + background_agent, both registered in langgraph.json, with triggerBackgroundAgentNode HTTP-dispatching via the SDK).
- The per-sub-agent memory prefix call chain (loadThreadSummariesForPrompt → trimMessagesForInvoke → buildSystemMessageWithMemory) and the <memory> + <threads> mustache template.
- save_memory input/output shapes, the 8-step write path (including mergeMemory on the merged view + filterToStoreOnly + assertProfileSize + invalidateMemory), and the fail-closed error classes.
- Thread summaries: store-anchored trigger (read back max(endMessageIndex) — heals deletion/replay), computeCumulativeWindow math, JSONL transcript rendering, structured LLM output, trimMessagesForInvoke cut-point rule.
- Memory tab UI (About-you rows with auth-overlay classification, Thread summaries with collapsed default + Radix animation).
- Configuration knobs (MEMORY_THREAD_SUMMARY_KEEP_RECENT, MEMORY_PROFILE_MAX_BYTES) and storage model (PostgresStore namespaces, delete-loop gotcha).
- Security stance (no forget tool, fail-closed userId, path regex, size guard, OAuth token redaction, cross-user 404) and known trade-offs.

CLAUDE.md — "What this is" rewritten to describe the dual-graph + memory + summarize nature. Architecture tree updated: added background-agent.ts, store.ts, callbacks.ts, trigger-background-agent-node.ts, thread-summarize-node.ts, tool/memory/, backend/memory/{recall,template,profile-size}.ts, with the real lib/memory/ function names. Backend graph section rewritten to cover both compiled graphs. State-persistence prose: afterAgentNode → touchLastMessageNode. New short 'Memory & thread summarize' pointer linking to MEMORY.md.

README.md — Features list: dual-graph bullet replaces the single-graph one; new 'Cross-conversation memory' bullet links to MEMORY.md; tool-using-agent bullet now mentions save_memory. Documentation table: MEMORY.md added as the second row. Environment table: MEMORY_THREAD_SUMMARY_KEEP_RECENT + MEMORY_PROFILE_MAX_BYTES entries. Project layout: fully rewritten to reflect the dual-graph backend, the memory tool/middleware modules, the lib/memory/ module, the tool-ui/memory/ cards, and langgraph.json registering BOTH graphs.

docs/TOOLS.md — 'Tool groups' table gets a Memory row. New '## Memory' section with the save_memory row + path regex / size-guard / no-forget-tool notes. Compliant with CLAUDE.md rule #1.

* chore: drop debug seed script and gate freshAge on NODE_ENV

Addresses two review comments on PR #7:

1. Remove scripts/seed-summary.ts — debug-temp file from the structured-summary
   refactor. No production / test code imports it (verified via repo-wide grep);
   safe to delete outright. Cleanup-observability.ts is the only script left.

2. Gate `freshAge: 0` on NODE_ENV !== 'production' in lib/auth/config.ts. The
   value disables better-auth's SESSION_NOT_FRESH check so dev fixtures can
   keep a single session for days without breaking the Security tab's
   "Active sessions" card. Production keeps the strict 24h default — session
   expiry (7d) still bounds exposure in dev.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant