Skip to content

feat(mental-models): dry-run refresh and keep_trace for troubleshooting - #3119

Merged
nicoloboschi merged 1 commit into
mainfrom
feat/mental-model-refresh-troubleshooting
Aug 3, 2026
Merged

feat(mental-models): dry-run refresh and keep_trace for troubleshooting#3119
nicoloboschi merged 1 commit into
mainfrom
feat/mental-model-refresh-troubleshooting

Conversation

@nicoloboschi

Copy link
Copy Markdown
Collaborator

Why

When a mental model refresh produces a document you didn't expect — nothing changed, the wrong things changed, delta edits didn't apply — the stored content doesn't say why. Every decision the refresh made (full vs delta and the reason, the resolved tag scope, the snapshot window, how much retrieval returned versus how much the agent used, which delta operations were dropped) existed only as a log line, and for cron- and consolidation-driven refreshes nobody is watching the logs.

This adds two ways to see that reasoning, from opposite directions.

Dry run

POST /v1/default/banks/{bank_id}/mental-models/{mental_model_id}/dry-run-refresh — synchronous, mirroring the existing /memories/dry-run-extract precedent.

It runs the real pipeline and reports what a refresh would do. Nothing is persisted: not content, structured document, watermark, nor last_refreshed_at. Because nothing is persisted, a delta dry run reads exactly the window the next real refresh will, and repeating it reads that same window again.

The response answers the questions the document can't:

Field Answers
requested_mode / effective_mode / mode_fallback_reason Did delta actually run, and if not, why
scope Which tags, match mode and fact types really filtered memories
window The created_after/created_before bounds and the watermark that would be written
facts.retrieved vs facts.used Retrieval found plenty but the agent used none — the most common cause of a disappointing refresh
delta_operations What was emitted, applied and skipped
diff Unified diff from stored content to what it would write
outcome / would_persist / warnings Whether it would write, keep, or fail

Every refresh setting is overridable in the body (mode, source_query, tags, tags_match, tag_groups, fact_types, …) to A/B a candidate configuration without editing the model.

keep_trace

A dry run only explains a refresh you run yourself. trigger.keep_trace records the same reasoning on every refresh of that model — including scheduled ones — under reflect_response.trace. It is written even when a refresh fails, which is when it matters most: a failed refresh otherwise leaves nothing behind to inspect.

Tool outputs are reduced to result counts so the stored trace stays bounded; raw prompts and responses remain available through LLM request tracing.

Implementation note

refresh_mental_model was split into a shared _execute_mental_model_refresh pipeline that computes a result and writes nothing, plus a thin persistence step. The dry run and the real refresh run the same body — a preview that reasoned differently from the refresh it predicts would be worse than no preview. Behaviour of the existing refresh path is unchanged.

Surfaces

  • Engine: shared pipeline, dry_run_refresh_mental_model, trace recording
  • API: new endpoint + trigger.keep_trace
  • Clients: OpenAPI, generated Python/TypeScript/Go/Rust, hand-written TS + Python wrappers
  • CLI: hindsight mental-model dry-run-refresh <bank> <id> [--mode] [--tags-match] [--source-query]
  • Control plane: a Diagnostics tab on the mental-model detail modal (run a dry run, see the diff/scope/window/facts/trace) and a "Keep refresh traces" toggle; 10 locales
  • Docs: a "Troubleshoot a Refresh" section on the mental-models API page

Tests

19 new tests in test_mental_model_dry_run_refresh.py covering: the dry run persists nothing (including that it does not advance last_refreshed_at), each mode-fallback reason, override plumbing reaching reflect without editing the model, retrieved-vs-used counts and their warnings, the empty-candidate outcome, scope resolution, trace presence/absence, trace on a failed refresh, and the HTTP surface (200, overrides, 404).

Full mental-model suite: 96 passed. lint.sh, cli-coverage-check, control-plane typecheck and i18n:check all clean.

Found along the way, deliberately not fixed here

#3112 — when a delta refresh's operations fail, the code logs "falling back to full synthesis" but the candidate was built from a delta-scoped recall, so writing it whole drops everything grounded in older memories. The dry run now warns about this condition; the behaviour itself is unchanged and tracked separately.

@nicoloboschi
nicoloboschi force-pushed the feat/mental-model-refresh-troubleshooting branch from 6cdfe84 to 3e2b9e1 Compare August 3, 2026 13:22
When a refresh produced an unexpected document, nothing said why. The mode
decision, resolved scope, snapshot window, retrieved-versus-used fact counts
and dropped delta operations only ever reached a log line — and cron- or
consolidation-driven refreshes run with nobody watching.

Two ways to see that reasoning, from opposite directions.

POST /mental-models/{id}/dry-run-refresh runs the production refresh
pipeline and reports what it would do, skipping exactly two writes: the
content (with its structured document and history entry) and the watermark
that moves last_refreshed_at. It takes no parameters, on purpose — a dry run
you can configure stops predicting the refresh it exists to predict. Because
nothing is persisted, a delta dry run reads exactly the window the next real
refresh will.

trigger.keep_trace records the same reasoning on every refresh of a model,
scheduled ones included, under reflect_response.trace. It is written even
when a refresh fails, which is when it matters most. The trace is shaped
like reflect's — the calls the agent made plus the refresh decision — and
holds nothing derivable from elsewhere: evidence stays in based_on, and the
resolved scope and window are reported by the dry run. Each tool call
records the window bound it was given, named `updated_at` for what the
predicate actually filters; null means the tool applies no time bound at
all, which is what explains results older than the window would suggest.

refresh_mental_model is split into a shared _execute_mental_model_refresh
that computes a result and writes nothing, plus a thin persistence step, so
the preview and the real refresh run the same body. Existing refresh
behaviour is unchanged.

In the control plane the dry run is an action on the mental model, and its
result opens in a dialog built from the History tab's own diff components.
History shows each version's own trace: the history snapshot now carries
`trace` alongside `based_on` so it survives being superseded.

Surfaced but deliberately not fixed here: when delta operations fail, the
fallback writes a candidate built from a delta-scoped recall over the whole
document, dropping content grounded in older memories (#3112).
@nicoloboschi
nicoloboschi force-pushed the feat/mental-model-refresh-troubleshooting branch from 3e2b9e1 to f747f6f Compare August 3, 2026 13:38
@nicoloboschi
nicoloboschi merged commit 06e9c70 into main Aug 3, 2026
104 checks passed
@nicoloboschi
nicoloboschi deleted the feat/mental-model-refresh-troubleshooting branch August 3, 2026 13:56
nicoloboschi added a commit that referenced this pull request Aug 3, 2026
Resolves conflicts with #3119 (mental-model dry-run refresh + keep_trace), which
refactored refresh_mental_model into _execute_mental_model_refresh:
- MentalModelTrigger/CLI/api.ts/UI: keep both response_schema and keep_trace fields.
- memory_engine: re-apply structured-output extraction + fail-loud into the new
  refresh_mental_model persist path (dry-run/executor left untouched).
- locales: deep-merge both key sets (+ the new mentalModelDiagnostics namespace).
- regenerate openapi/clients/bank-template-schema/docs-skill for both new fields.
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