Skip to content

fix(chat): the model was never being shown the conversation - #186

Merged
ihabkhaled merged 9 commits into
mainfrom
feat/conversational-context-v2
Aug 30, 2026
Merged

fix(chat): the model was never being shown the conversation#186
ihabkhaled merged 9 commits into
mainfrom
feat/conversational-context-v2

Conversation

@ihabkhaled

Copy link
Copy Markdown
Owner

A user reported that ClawAI forgets the earlier part of a long conversation. It does — but not because the conversation is long.

Reproduced against production: total context loss at turn three of a three-turn thread.

The measurement that settled it

One planted fact, one thread, one distance, six free models, the same question phrased four ways:

Phrasing Word overlap with the seeding sentence Recall
What is my access code for this session? 0.50 83%
Which secret string did I share at the start? 0.00 0%
Remind me of the credential I mentioned earlier. 0.00 0%
Repeat it back to me. 0.00 0%

24 of 24 threads matched the prediction computed from the selector's own arithmetic before the run. A separate breadth run scored 19/19 free models at 100% on the high-overlap phrasing, which rules the models out as the variable.

Recall was a function of how many four-letter-or-longer words the question happened to share with the sentence that stated the fact. Rephrase the question and the fact vanishes.

Root cause

Three caps in series, each unaware of the others:

# Where Rule Effect
1 chat-messages.service.ts findRecentByThreadId(threadId, 20) Only 20 rows ever left the database
2 context-assembly.manager.ts threadMessages.slice(-20) Cut again, at a message boundary, splitting turns
3 filterThreadMessagesForIntent drop all ASSISTANT · overlap ≥ 0.45 · slice(-4) Cut 20 down to 1–6

Plus a 1,024-token clamp on the AUTO fast path (triggered by short prompts — exactly the ones that depend on long histories), and tokenBudget = maxTokens ?? 4096: an output length used as the whole-prompt budget. contextWindowTokens existed in routing-service, connector-service and the frontend, and in zero files of chat-service.

What changed

One rule: nothing removes a message for being irrelevant; only the token budget removes. Relevance decides order, and order only matters once the budget is full.

  • Turn-based selection — an answer never arrives without its question
  • Assistant output is conversational state, never dropped for its role
  • ModelTokenBudget splits five quantities that were one; maxTokens now feeds reservedOutputTokens and nothing else
  • The real context window comes from the catalog over a cached internal route that fails open (an unknown window must shorten a prompt, not deny an answer)
  • Every generation emits a context manifest into the receipt — included ids, omitted ids with per-message reasons, token accounting, budget provenance
  • Cross-thread retrieval, off by default, two-stage, user-scoped twice
  • Chat generation migrated to the canonical memory retrieval route; memory-service internal routes now require a service token
  • A keyed, half-open circuit breaker so a dead Ollama dependency stops being retried on every message

Results, measured on a deployment running this code

The 60-turn gauntlet: 30/168 (18%) → 80/85 (94%). Excluding the two probe classes whose wording overlaps the seeding sentence, production scored 1 of 149.

Probe Production Fixed
recall @ d4 / d24 / d56 0/15 · 0/15 · 0/14 8/8 · 8/8 · 8/8
latest-value precedence 0/15 8/8
assistant-response recall 0/14 7/8
coreference 0/15 7/8
final synthesis 0/14 6/8
topic return 0/3 3/3

Authorization: 13/13 denied, zero leakage, including the decisive one — an attacker enabling cross-thread retrieval on their own thread and asking for the victim's secret by name.

Assembly cost (server-side, from the manifest): selection 0–1 ms at 159 messages while sending all 159. Retrieval 3,850 ms → 8–16 ms after the circuit breaker; 5,000 ms (timeout) → 24 ms at 16 concurrent after adding half-open.

2,427 free-model generations across 13 runs. No metered model executed — enforced in code, not by naming convention.

Reviewing this

Start with docs/13-adr/adr-086-conversational-context-composer.md — it carries the evidence and the reasoning. Then adr-087 for cross-thread retrieval, which is shaped more by what it refuses than by what it finds.

context-composer.live-replay.spec.ts replays 24 real production transcripts as a committed fixture: the shipped selector starved 18 of them, production lost the fact in all 18, and the composer restores it in 24/24. The 19th live failure was a model refusal, asserted separately so the two are never conflated.

Deploy order matters

chat-service and workspace-service before memory-service. Both gained service-auth headers; memory-service now requires them. Backwards means every retrieval 401s — not an outage (memory is non-blocking) but memories silently disappear. Full procedure in docs/08-runtime-devops/conversational-context-rollout.md.

Not in this PR, stated plainly

  • Hierarchical summarisation — beyond 400 rows (200 turns) the oldest content is not loaded. Blocked on a product/billing decision, not effort: every model call in chat-service goes through the token-deduction chokepoint, so summarising a user's thread would silently spend their allowance on something they did not ask for. An extractive digest avoids the question and is the recommended path.
  • Semantic/vector retrieval — cross-thread search matches terms, not meaning. Precision over recall is the deliberate trade.
  • Structured supersession — latest-value precedence is served by recency weighting (measured 8/8), a strong heuristic rather than a guarantee.
  • Feature flag — deliberately none. The off state here would be the shipped defect; a flag whose disabled path is a known bug is worse than no flag. Rollback is a revert, and cross-thread is already opt-in per thread.
  • Soak and chaos suites — a concurrency baseline to 16 exists; no multi-hour soak, no deliberate failure injection.
  • glm-5.3 is ACTIVE + isExecutionCapable in the router catalog and returns 403 MODEL_NOT_EXPOSED on every send. Unrelated, still open, filed here because the router offers a model users cannot use.

Gates

chat-service 1,658 tests · routing-service 1,391 · workspace-service 1,136 · memory-service 98. Typecheck, lint and build green on all four and the frontend. 13/13 locales complete. Merged with main (v1.58.0) and re-verified.

🤖 Generated with Claude Code

ihabkhaled and others added 9 commits August 30, 2026 16:42
…ne message

A user reported ClawAI forgetting earlier parts of long conversations. A live
lab against production reproduced total context loss at turn THREE of a
three-turn thread, and length turned out to be irrelevant.

Three independent caps sat in series:

  1. the DB read itself: findRecentByThreadId(threadId, 20)
  2. assemble(): threadMessages.slice(-THREAD_CONTEXT_LIMIT)
  3. filterThreadMessagesForIntent(): dropped EVERY assistant message, kept
     user messages scoring >=0.45 on word overlap with the current question,
     then .slice(-4) - or messages.slice(-1) when nothing scored.

Plus a 1024-token prompt clamp on the AUTO fast path, triggered by prompts
under 220 characters - precisely the short questions that depend on long
histories. And tokenBudget was `maxTokens ?? 4096`: an OUTPUT length used as
the whole-prompt budget, so a 256k-window model received ~16 KB of history,
memories, files and system prompt combined. `contextWindowTokens` existed in
routing-service, connector-service and the frontend, and in zero files of the
service deciding how much to send.

Measured, 6 free models, same fact, same thread, same distance, four phrasings
of one question:

  "What is my access code for this session?"        overlap 0.50 -> 83% recall
  "Which secret string did I share at the start?"   overlap 0.00 ->  0% recall
  "Remind me of the credential I mentioned earlier" overlap 0.00 ->  0% recall
  "Repeat it back to me."                           overlap 0.00 ->  0% recall

24/24 threads matched the static prediction. A breadth run scored 19/19 free
models at 100% on the high-overlap phrasing, ruling the models out. Recall was
a function of how many four-letter words the question shared with the sentence
that stated the fact.

ContextComposerManager replaces the selector, under one rule: nothing removes a
message for being irrelevant; only the token budget removes. Relevance decides
order, and order only matters once the budget is full. Selection works in whole
turns, assistant output is never dropped for its role, and eviction walks P3
upward rather than oldest-first.

ModelTokenBudget separates contextWindowTokens / reservedOutputTokens /
systemOverheadTokens / toolOverheadTokens / availableInputTokens. maxTokens now
feeds reservedOutputTokens and nothing else. routing-service gains an internal
context-window route; chat-service reads it through a 15-minute cached client
that fails OPEN - an unknown window must shorten a prompt, not deny an answer.

Every generation now emits a ConversationContextManifest into the context
receipt: included ids, omitted ids with per-message reasons, token accounting,
budget provenance. The receipt used to skip itself when there were no memories
and no pack items, which is most chat turns, so the one surface that could have
shown this did not exist for the threads that had it. Surfaced in the thread
context inspector, 13 locales.

context-composer.live-replay.spec.ts replays the 24 real production threads:
the shipped selector starved 18 of them, production lost the fact in all 18,
and the composer restores it in 24/24. The 19th live failure was a model
refusal, asserted separately so the two are never conflated.

Gates: chat-service 1640 tests, routing-service 1391 tests, typecheck + lint +
build green on both; frontend typecheck + lint green, 13/13 locales complete.

NOT included, and not claimed: cross-thread retrieval (does not exist),
hierarchical summarisation, structured supersession, vector same-thread
retrieval, and chat's migration off the legacy memory route. See ADR-084.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n off

ADR-084 fixed what a thread knows about itself. This is the other half of the
complaint: starting a new conversation about a project discussed last week and
finding ClawAI has never heard of it. Measured before this change, a thread
that spent three turns establishing facts about MERIDIAN-88 was invisible to a
new thread asking to continue it.

Done carelessly this produces the worst behaviour a conversational product can
have — answering from a conversation the user is not in, about a project they
did not mention. The design is shaped more by what it refuses than what it
finds.

  OFF BY DEFAULT. `useCrossThreadContext` defaults false; the migration adds
  the column with DEFAULT false so every existing thread is unchanged. When
  off the repository is never called: opt-out means "not read", not "read and
  then discarded", because the second still exposes the data to a bug in
  whatever discards it.

  TWO STAGES. Stage 1 asks the database which of this user's non-archived
  other threads mention the prompt's salient terms, ranked by matching-message
  count; stage 2 reads only the top three and scores individual messages. A
  single-stage search would surface a sentence sharing vocabulary with the
  prompt, torn out of a conversation about something else.

  A COINED IDENTIFIER IS THE PRECISION GATE. When the prompt contains one it
  is the only thing searched. "Continue the MERIDIAN-88 project, which package
  manager?" searched on all its terms matches every thread that ever mentioned
  a package manager; searched on MERIDIAN-88 it matches the one the user means.

  USER-SCOPED TWICE. No repository method can be called without a userId, and
  stage 2 re-proves ownership rather than trusting the ids stage 1 handed it.
  Archived threads are excluded; deleted threads leave nothing to retrieve
  because messages cascade on delete.

  15% OF THE INPUT BUDGET, subtracted before the composer runs, so retrieved
  material can never displace the conversation the user is actually in.

  SEVEN NAMED SKIP REASONS written to the receipt with the threads searched
  and used, so "nothing was retrieved" is never ambiguous. Retrieval fails
  silent: an error records RETRIEVAL_FAILED and the turn continues.

Two things the live run taught, both now encoded:

  Ranking on the thread TITLE alone failed its first test. A thread that had
  discussed MERIDIAN-88 for three turns carried a title that did not name it,
  scored 0.03 against a 0.28 threshold, and was never read. A title is
  auto-derived, often absent, and renameable. The evidence that a thread is
  about something is in the thread, so ranking now counts matching messages.

  The toggle returned 200 OK and did nothing, because ChatThreadsService maps
  DTO fields to repository fields one line at a time and nobody added the new
  line. Nothing in the type system catches it — every field is optional, so an
  object missing one is still valid. Added a source-level guard that reads both
  DTO schemas and asserts every field is named on the corresponding write,
  scoped per method (the first version read the whole file and passed over the
  very bug it was written for).

Verified live, three threads on kimi-k3: toggle off retrieves nothing
(DISABLED); toggle on finds the MERIDIAN-88 threads and answers pnpm +
Frankfurt; a project never discussed returns NO_CANDIDATES. In the third case
the model invented an answer anyway — the manifest shows nothing was retrieved,
so that is a hallucination and not a leak. Scoring on the manifest rather than
the model's words is what keeps those apart.

Also in this batch, the before/after for ADR-084 measured at scale rather than
asserted. Production baseline: 1209 turns, 168 probes, 30 passed (18%), and both
passing probe classes are the two whose wording happens to overlap the seeding
sentence. The same gauntlet against a deployment running the composer: 80/85
(94%). recall@d4 0/15 -> 8/8. latest-value 0/15 -> 8/8. assistant-response
recall 0/14 -> 7/8. topic return 0/3 -> 3/3. 2,427 free-model generations
across ten runs, no metered model executed.

Gates: chat-service 1655 tests, typecheck + lint + build; frontend typecheck +
lint; 13/13 locales complete.

NOT included: semantic/vector recall (this is term matching — a thread about
"Postgres" will not match a prompt about "relational databases"), hierarchical
summarisation, structured supersession, and chat's migration off the legacy
memory route. See ADR-085.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
084 is already taken by in-flight SEO work on main (SEO clusters fan out from
one dynamic route). Two ADRs claiming the same number is not a cosmetic clash:
every cross-reference in the code comments resolves to the wrong decision, and
the index cannot hold both.

Renumbered before the collision reached main rather than after. 085 is left
free in case the SEO stream needs it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-086 and ADR-087 each widened what one request can read — a manifest naming
message ids, a receipt naming prior threads, and a retrieval path that
deliberately reads OTHER conversations. Every one of those is a place a missing
owner filter leaks a different customer's chat, and none of them had a test
that a second real account could not reach them.

Thirteen probes with a second ordinary USER: read the thread, its messages, one
message, the context receipt, preview its context, post into it, flip its
cross-thread toggle, delete it, branch it, search inside it, read the receipt
unauthenticated, call the internal routing route with a user token, and — the
decisive one — enable cross-thread retrieval on the attacker's own thread and
ask for the victim's planted secret by name.

Result: 13/13 denied, no secret in any response body, `priorThreadsUsed` empty
on the cross-user probe.

A 400 is scored as a FAILURE, not a denial. The first run reported one because
the probe used the wrong query parameter name; counting 400 as "denied" would
have recorded safety for a request the server never evaluated, which is exactly
how a security suite comes to certify something it did not measure.

The attacker is created through the admin route because self-registration gates
login behind email verification. It is an ordinary USER with no elevated
permissions — how it was created says nothing about what it can reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…outes need a service token

Two findings from the 2026-08-30 audit, closed together because they touch the
same four call sites.

F-05 — the preview described a different code path from the answer.

  Chat generation read `GET /internal/memories/for-context`: a user's most
  recent N memories, with no intent, no ranking, no score, no retrieval reason
  and no usage telemetry. The scoring that decided which of them a model
  actually saw then happened inside a chat-service method, out of reach of the
  service that owns memory.

  `POST /chat-threads/:id/preview-context` — the endpoint behind "what will the
  AI see?" — already used the canonical `POST /internal/memories/retrieve`. So
  the preview a user was shown was produced by different code from the
  generation it claimed to describe, and the two could disagree with nothing
  anywhere reporting that they had.

  Verified live: preview and generation now return identical memory id lists,
  and a saved standing INSTRUCTION reaches the model and is obeyed.

Security — memory-service's internal routes had no service identity check.

  They were `@Public()` with nothing behind it, while five of the six services
  that expose internal routes already had a `ServiceTokenGuard`. They are not
  reachable from the internet: nginx routes exactly one `/api/v1/internal/*`
  prefix (chat-shares) and everything else falls through to the frontend, which
  the audit confirmed by probing both deployments. But the routes take a
  `userId` as a plain query parameter and return that user's memories, so
  anything able to reach the container could read any account's memories by
  guessing an id — and "nginx does not route it" is one config line away from
  being false.

  Applied in the safe order: all four callers gained the header first (additive,
  cannot break anything), then the guard. The four were chat-service's
  generation and preview paths and workspace-service's learned-preference read
  and automation-preference write — none of which had ever sent one.

  The guard mirrors routing-service's and auth-service's exactly; the three must
  agree on the header format or the hop fails closed and looks like an outage.
  Constant-time comparison, so the shared secret cannot be recovered a byte at a
  time from response latency.

Gates: memory-service 88 tests, workspace-service 1136, chat-service 1655;
typecheck + lint + build green on all three. Live: 4/4 on the memory experiment
after the guard was enabled, which is also the proof the header wiring is right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ackend every turn

Instrumented first, because the obvious measurement was wrong. End-to-end turn
latency across the gauntlet produced a suspiciously flat 5.8 s p50 at every
thread length — that was the QA harness's poll interval, not the server. A
latency number that constant should be read as a broken instrument.

So the manifest now carries the server's own numbers, split by where the time
goes:

  retrievalMs — network. Memories, packs, files, workspace and cross-thread,
                fetched concurrently. Flat in thread length by construction.
  selectionMs — the composer's own work: grouping into turns, scoring, fitting
                to budget. The one that could grow.

Keeping them apart is what makes "context assembly got slower" distinguishable
from "memory-service got slower". Both are surfaced in the thread context
inspector alongside the cross-thread counts, in 13 locales.

What the measurement then found.

  selectionMs is 0 ms at 9, 29, 59 and 99 messages and 1 ms at 159 — while the
  composer sent EVERY message in the thread (159 of 159, 80 turns, 3,741
  tokens). Selection is not the cost, at any length tested.

  retrievalMs was 3,845–3,861 ms on every single turn. memory-service embeds
  the query before searching; with no embedding model installed the call fails
  after ~4 s, retrieval swallows the failure and returns results anyway — and
  pays the four seconds again on the very next turn, and the one after that.

  The failure was always there. Migrating chat generation to the canonical
  retrieval route (F-05) is what put it in front of every message.

`embedding-circuit.utility.ts` opens after three consecutive failures — not
one, because a single timeout is a blip and opening on it would disable
semantic search for a transient hiccup — stays open 30 s, and closes on the
next success. Module-level rather than injectable: it protects one external
dependency shared by every caller in the process, and a per-instance breaker
would open once per collaborator instead of once.

Measured on the same stack, same script, before and after:

  retrieval  3,845–3,861 ms  ->  8–16 ms
  selection          0–1 ms  ->  0–1 ms

A dead embedding backend now costs one timeout per half-minute instead of one
per turn, and an operator who installs the model gets semantic search back
within 30 s without restarting anything.

Gates: memory-service 95 tests, chat-service 1657, typecheck + lint + build
green; frontend typecheck + lint green, 13/13 locales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by measuring, not by reading. A concurrency baseline over the context path
showed `selectionMs` flat at 0-1ms from 1 to 16 concurrent generations — the
composer is not the bottleneck at any level — while `retrievalMs` jumped from
~26ms to a flat 5000ms at 16 concurrent. 5000 is exactly chat-service's memory
timeout, so retrieval was not slow, it was being starved.

memory-service makes three unattended calls to ollama-service: embeddings for
semantic search, generation for memory extraction, generation for sensitivity
classification. All three swallow their failures so a missing model degrades the
feature rather than breaking the request, which is right — and hid that each was
retried on the very next message. With no model installed, extraction fails
after ten seconds and runs once per message. Sixteen of those in flight starved
the retrieval path into its own timeout: a dead OPTIONAL feature taking down a
working one.

The breaker is keyed, not global. Embeddings and generation are different
endpoints that fail independently, and one breaker for both would disable
working semantic search because extraction was down.

Half-open is the part that actually fixed it, and the first attempt without it
is worth recording: a breaker that only counts failures throttles how OFTEN a
dead dependency is hammered but not how MANY calls go at once. The moment its
30-second window expired, all sixteen waiters were admitted together and the
starvation returned unchanged — measured, not predicted. Now exactly one call is
admitted as a trial while everyone else keeps failing fast until it resolves.

retrievalMs p50/max at 16 concurrent, same stack, same script:

  no breaker              5000 / 5000   (timeout)
  breaker, no half-open   5000 / 5001   (timeout)
  breaker + half-open       24 / 3861   (the 3861 is the single probe)

Also bounds the extraction timeout in a named constant rather than an inline
10_000, so it can be lowered without hunting for it.

Ships with the rollout guide: deploy order (callers before memory-service, or
every retrieval 401s), what to watch in the receipt, and why there is no feature
flag — the off state of one here would be the shipped defect.

Gates: memory-service 98 tests, typecheck + lint + build green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ontext-v2

# Conflicts:
#	.ai/manifests/hashes.json
#	.ai/manifests/services.json
#	.ai/manifests/tests.json
#	docs/13-adr/adr-index.md
#	docs/features/ai-native-engineering-os/inventory.snapshot.json
…ontext-v2

# Conflicts:
#	.ai/BOOTSTRAP.md
#	.ai/manifests/hashes.json
#	.ai/manifests/services.json
#	.ai/manifests/tests.json
#	docs/features/ai-native-engineering-os/inventory.snapshot.json
@ihabkhaled
ihabkhaled merged commit 097bc97 into main Aug 30, 2026
113 of 114 checks passed
@ihabkhaled
ihabkhaled deleted the feat/conversational-context-v2 branch August 30, 2026 19:25
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