Skip to content

Issue #212: feat: Implement LangChain message trimming - #1148

Open
dereck-symmetry wants to merge 3 commits into
mainfrom
issue-212---LangChain-trimming
Open

Issue #212: feat: Implement LangChain message trimming#1148
dereck-symmetry wants to merge 3 commits into
mainfrom
issue-212---LangChain-trimming

Conversation

@dereck-symmetry

@dereck-symmetry dereck-symmetry commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

This issue implements a new feature where LangChain message trimming is implemented.
The changes can be tested by Claude in an automated fashion by deployment. It can also be manually tested.

#718 is related to this issue. However the fix for that is NOT implemented here.

Refs #212
Refs #718

Type of Change
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality
    to not work as expected)
  • Documentation update
  • Infrastructure/deployment change
  • [X ] Performance improvement
  • Code refactoring
Project Area(s) Affected
  • bases/
  • components/
  • projects/
  • orchestrators/
  • frontends/
  • deployments/
  • cloudformation/ or sam/ templates
  • reference_data/
  • scripts/
  • test/ or e2e/
  • Database schema (migrations)
  • API endpoints
  • Documentation (docs/, READMEs, ARCHITECTURE.md, CLAUDE.md)
Checklist
  • commit message follows commit guidelines (see commitlint.config.mjs)
  • documentation is changed or added (in /docs directory)
  • code passes linting checks (uv run ruff check)
  • code passes formatting checks (uv run ruff format)
  • code passes type checking (uv run ty check)
  • pre-commit hooks have been run successfully
  • API changes: base (Python code) documentation in docs/
    and project README updated
Testing
  • Manual testing performed
  • Automated tests added/updated
  • Integration testing completed
Additional Notes

Testing found a new possible bug with 1-org deployments. Author will create a new issue and discuss with the dev team.

Note that testing for this PR uses LLM for automated testing.

@bjagg bjagg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Implements token-budget trimming in the Advisor's pre_model_hook, replacing the # TODO Add safe trim_messages here placeholder with a real _safe_trim_messages helper, plus a new test_memory.py (6 tests) and an ADR update.

The trimming implementation itself is good, and one comment in particular is the kind of thing that only comes from actually thinking about the failure mode:

# Note: start_on is deliberately NOT set - it would force the
# trimmed history to end on a HumanMessage, dropping trailing AI/tool messages
# even when within budget and breaking multi-step tool use.

That is a subtle trap correctly avoided, and test_safe_trim_keeps_tool_call_tail_within_budget locks it in. include_system=True protecting the summary at index 0, strategy="last", and the empty-trim fallback with a warning are all the right calls.

Must fix: this PR deletes the #772 KT artifact

diff --git a/.claude/plans/772-import-transformation-groups.md
deleted file mode 100644

That file is Chris Beach's knowledge-transfer document for the remaining transformation-import work (Layers 2-4, now tracked as #1140/#1141/#1142). It was deliberately corrected and merged ten days ago in #1143 specifically so the remaining layers would not live only in one person's head — Chris's hours on the project have run out.

The deletion isn't mentioned in the PR description, and it's paired with a .gitignore addition of .claude/plans/ labelled "Local AI-agent working files (not source-controlled)". I understand the instinct — agent scratch space generally shouldn't be committed — but this particular file was promoted to a tracked artifact on purpose.

Please drop both the deletion and the .gitignore hunk from this PR. If the convention is worth having, it deserves its own PR and a decision about where KT documents should live instead — the same .gitignore hunk is also in #1149, so it's currently being proposed in three places at once.

Design question: the token budget only applies post-summarization

state["messages"] = _safe_trim_messages(...) sits inside if summarized_messages:, which is only reachable when len(state["messages"]) > max_messages triggers summarization. So a conversation at or below MESSAGES_TO_KEEP (default 4) messages is never trimmed regardless of its token count — four long tool results, or one very large message, pass through untouched.

test_short_conversation_is_not_summarized encodes this (assert result["messages"] == conversation), so it reads as intentional. But it's worth stating explicitly, because #718 — "Failed to trim messages to fit within max_tokens limit before summarization" — is precisely that uncovered path. The PR body says #718's fix is deliberately out of scope, which is a reasonable scoping call. Two suggestions:

  1. Say so in the code, next to the if summarized_messages: branch — the fact that the token budget is not enforced on the short-conversation path is non-obvious and will otherwise be re-derived by whoever picks up #718.
  2. Confirm #212 should close on this PR. If #212 is "implement trimming" then yes; if it's understood as "the message list sent to the LLM always fits the budget," then it's partially addressed and #718 carries the rest.

Configuration note

TRIMMED_MESSAGES_SIZE defaults to 384 (core.py:46), which is the same value as the summarizer's max_conversation_size default. Since the trim runs on summary + retained messages after summarization, having the two budgets coincide means the trim will rarely bind. Worth confirming the number was chosen rather than inherited — and a brief note on how the two budgets relate would help.

Test coverage

Six tests, and they cover the right things: trims when over budget, no-ops when under, preserves the system message, preserves the latest human message, keeps the tool-call tail, and falls back on empty. Asserting on caplog for the fallback warning is a nice touch.

Verdict

Request changes — solely for the KT artifact deletion, which is a merge blocker. The trimming work itself is ready and I'd approve it on its own.

Recommend removing the .claude/plans/ deletion and .gitignore hunk, then adding a comment on the post-summarization scoping. Happy to re-review promptly.

@dereck-symmetry

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all three points addressed in d909e74:

KT artifact deletion (blocker) — Agreed, and apologies. .claude/plans/772-import-transformation-groups.md is restored byte-identical to base (blob ff17a53a), and the .gitignore hunk is reverted entirely — this PR no longer touches agent-scratch conventions.

Trim scoping (#718) — Documented rather than left to be re-derived: a comment above if summarized_messages: now states that short conversations (≤ MESSAGES_TO_KEEP) go to the LLM untrimmed regardless of token count, with enforcement before summarization tracked as #718. Also aligned the ADR pseudo-code snippet with the implementation (trim inside the summarize branch; condition corrected from token count to message count) so the docs no longer imply an unconditional budget.

TRIMMED_MESSAGES_SIZE = 384 — Kept at 384, but your suspicion of inheritance was half right: the code default matches MAX_CONVERSATION_SIZE, while every deployment artifact (compose stacks, run script, ECS taskdef) overrides to 2048/1024/384 per the ADR. While documenting the relationship we found something worth escalating beyond a comment: langmem does not enforce max_summary_tokens on the summarizer LLM, and the summary is emitted as a SystemMessage that our trim always keeps first. So under the deployed trio (summary estimate 1024 > trim 384), any real summary over ~380 tokens collapses the trimmed list to [summary] alone — non-empty, so the fallback warning never fires and recent turns/tool results are dropped silently. The code comment now states this ceiling relationship precisely; the tuning work (bind summary length via model.bind(max_tokens=...), size trim ≥ cap + headroom, guard the collapse case) is filed as #1160.

Also: PR body changed Closes #212Refs #212 per your point that #212 reads as "the LLM list always fits the budget" — #718 carries the remaining path.

Re-requesting your review.

@dereck-symmetry
dereck-symmetry requested a review from bjagg August 23, 2026 16:48
bjagg added a commit that referenced this pull request Aug 23, 2026
…1163)

##### Description of Change

**Problem.** The demo Advisor has been failing its synthetic monitor
since 2026-08-22, and the alerts are still firing. Every run dies the
same way: the typing indicator never clears because the Advisor is
getting a 429 from OpenAI.

```
openai.RateLimitError: 429 - Request too large for gpt-4.1-mini on tokens per min (TPM):
Limit 2000000, Requested 3200202  -> 3406017 -> 3503940
```

**Cause.** `pre_model_hook` mutated the state dict in place and returned
the whole `ChatState`. LangGraph treats a `pre_model_hook` return value
as a *state update*, and `AgentState.messages` is declared
`Annotated[Sequence[BaseMessage], add_messages]` — an **append** reducer
that only replaces entries whose message IDs already exist. Our hook
returned a plain list with no `RemoveMessage(REMOVE_ALL_MESSAGES)`
sentinel, so the retained messages were ID-matched no-ops while **each
new summary carried a fresh ID and was appended**. Nothing was ever
removed. Every turn the model received the full prior history *plus* one
more summary block, so the hook that exists to bound the context was
growing it instead.

The framework says so directly, in
`langgraph/prebuilt/chat_agent_executor.py` (langgraph-prebuilt 0.2.3,
the version we ship):

> At least one of `messages` or `llm_input_messages` MUST be provided.
> `"messages"` — *If provided, will UPDATE the `messages` in the state*.
> `"llm_input_messages"` — *If provided, will be used as the input to
the LLM, and will NOT UPDATE `messages`*.
> **Warning:** if returning `messages`, you should OVERWRITE the key:
`{"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages]}`

There was a standing tell in the logs, once per agent invocation, easy
to read as noise — it is the signature of returning a whole state where
a scoped update is required:

```
WARNING | langgraph | Task pre_model_hook ... wrote to unknown channel remaining_steps, ignoring it.
```

**Solution.** Return a scoped update that hands the trimmed list to the
model via `llm_input_messages`, which feeds the LLM without mutating
`messages`, and return only the keys that must persist. `context` must,
so the summarizer can tell it already summarized. `remaining_steps` is a
managed value and must never be written back.

Two latent problems in the same function are fixed alongside it:
- `messages_to_retain` was bound inside the `if len(...) >
max_messages:` branch but read outside it — safe only because it was
read solely when a summary existed, and an `UnboundLocalError` waiting
on any future edit.
- The no-summarization path returned no LLM input at all, which
`create_react_agent` rejects.

The pre-existing `# ty: ignore[invalid-assignment]` is gone too, since
we no longer assign into the TypedDict.

**Side effects / limitations.** Behavior-only change to how the hook
reports its result; no API, schema, or config change. Because trimming
now actually takes effect, the model sees a genuinely shorter history
than it did before — that is the intent, but it means summarization
quality (`LIF_ADVISOR_MAX_CONVERSATION_SIZE`, default 384) is now
load-bearing in a way it previously was not, since the untrimmed history
was silently backstopping it.

**How to test.** `uv run pytest test/components/lif/langchain_agent/` —
and to see the regression, stash `memory.py` and re-run: 5 of the 6 new
tests fail against the old code.

##### Related Issues

Closes #1162
Refs #212, Refs #718

##### Type of Change

- [x] Bug fix (non-breaking change which fixes an issue)

##### Project Area(s) Affected

- [x] components/
- [x] test/ or e2e/

---

##### Checklist

- [x] commit message follows commit guidelines (see
commitlint.config.mjs)
- [x] tests are included (unit and/or integration tests)
- [x] code passes linting checks (`uv run ruff check`)
- [x] code passes formatting checks (`uv run ruff format`)
- [x] code passes type checking (`uv run ty check`)
- [x] pre-commit hooks have been run successfully

##### Testing

- [x] Automated tests added/updated

The regression test merges the hook's update through the **real**
`add_messages` reducer and reads the LLM input the way `call_model` does
(`llm_input_messages` if present, else `messages`), so it reproduces the
production failure rather than asserting on an idealized contract:

| | LLM input after 30 turns |
|---|---|
| before | **88 messages**, growing linearly |
| after | **5**, flat |

Full suite: 684 passed.

##### Additional Notes

This supersedes #212/#718 as the immediate cause of the demo outage.
Worth flagging for the review queue: **#1148's trimming would not have
prevented this**, because it runs downstream of the same broken return
path.

Once merged this needs promotion to demo to actually clear the alerts —
the demo advisor runs a pinned image tag.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants