diff --git a/backend/packages/harness/deerflow/agents/memory/prompt.py b/backend/packages/harness/deerflow/agents/memory/prompt.py index 463b92ac1..58b845634 100644 --- a/backend/packages/harness/deerflow/agents/memory/prompt.py +++ b/backend/packages/harness/deerflow/agents/memory/prompt.py @@ -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 diff --git a/backend/tests/test_memory_prompt_injection.py b/backend/tests/test_memory_prompt_injection.py index b103c83b0..4fa0132c7 100644 --- a/backend/tests/test_memory_prompt_injection.py +++ b/backend/tests/test_memory_prompt_injection.py @@ -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.