Fix UnboundLocalError in memory injection when facts are empty (#3992)

`format_memory_for_injection` bound `facts_header` / `all_fact_lines` only
inside the `if isinstance(facts_data, list) and facts_data:` block, but the
structure-aware overflow-truncation path at the end of the function
references both unconditionally.

When a user's memory has sizeable user-context / history (so `sections` is
non-empty and the assembled output exceeds `max_tokens`) but an empty or
missing `facts` list, that block is skipped, so the truncation branch hits
`UnboundLocalError: cannot access local variable 'all_fact_lines'` and
aborts memory injection entirely.

Hoist the two initializers to function scope, alongside the existing
`guaranteed_line_tokens = 0`, so they are always bound. Behaviour is
unchanged when facts are present.

Adds a regression test (empty facts + oversized user context) that fails
with UnboundLocalError before the fix and truncates gracefully after.
This commit is contained in:
Daoyuan Li
2026-07-10 22:27:56 +08:00
committed by GitHub
parent 1fa91fa39d
commit a2a949b178
2 changed files with 37 additions and 7 deletions
@@ -589,6 +589,13 @@ def format_memory_for_injection(
# performs a single-pass confidence-only ranking.
facts_data = memory_data.get("facts", [])
guaranteed_line_tokens = 0 # used later for the effective truncation limit
# Initialise the facts-block markers at function scope (alongside
# ``guaranteed_line_tokens`` above) so the structure-aware truncation at the
# bottom can reference them even when there are no facts and the block below
# never runs. Otherwise the overflow path raises ``UnboundLocalError`` when a
# user has sizeable context/history but an empty ``facts`` list.
facts_header = "Facts:\n"
all_fact_lines: list[str] = []
if isinstance(facts_data, list) and facts_data:
# Token cost of sections built above (user context, history).
base_text = "\n\n".join(sections)
@@ -599,13 +606,6 @@ def format_memory_for_injection(
# redoing validation work on the hot prompt-injection path.
valid_facts = [f for f in facts_data if isinstance(f, dict) and isinstance(f.get("content"), str) and f.get("content", "").strip()]
# Initialise the facts-block markers *before* the try so the
# structure-aware truncation at the bottom of the function can
# reason about them regardless of whether the primary path or
# the except/fallback path produced the final Facts section.
facts_header = "Facts:\n"
all_fact_lines: list[str] = []
try:
# Partition valid facts into guaranteed vs regular groups.
# Use the *raw* category field (no ``or "context"`` default) so
@@ -508,6 +508,36 @@ def test_structure_aware_truncation_preserves_guaranteed_on_overflow(monkeypatch
assert result.rstrip().endswith("(avoid: pip is deprecated)")
def test_structure_aware_truncation_no_facts_does_not_raise(monkeypatch) -> None:
"""When preceding sections overflow but there are no facts at all, the
truncation path must still clip gracefully instead of raising
``UnboundLocalError``.
Regression: ``facts_header`` / ``all_fact_lines`` were only bound inside the
``if isinstance(facts_data, list) and facts_data:`` block, yet the
overflow-truncation path below references them unconditionally. With an empty
``facts`` list and an oversized user-context section, the truncation branch
raised ``UnboundLocalError`` and aborted memory injection entirely.
"""
monkeypatch.setattr(
"deerflow.agents.memory.prompt._count_tokens",
lambda text, encoding_name="cl100k_base", *, use_tiktoken=True: len(text),
)
memory_data = {
"user": {"workContext": {"summary": "X" * 4000}},
"facts": [], # no facts -> the facts-block initializers are skipped
}
result = format_memory_for_injection(memory_data, max_tokens=200, use_tiktoken=False)
assert isinstance(result, str)
assert "User Context:" in result
# The oversized preceding section was clipped from the tail.
assert result.rstrip().endswith("...")
assert len(result) < 4000
def test_single_inter_section_separator_between_user_and_facts() -> None:
"""[P2] Exactly one ``\\n\\n`` separator between ``User Context:`` and
``Facts:`` — never four newlines.