diff --git a/hindsight-api-slim/hindsight_api/engine/consolidation/prompts.py b/hindsight-api-slim/hindsight_api/engine/consolidation/prompts.py index 342402589..a0eb084f2 100644 --- a/hindsight-api-slim/hindsight_api/engine/consolidation/prompts.py +++ b/hindsight-api-slim/hindsight_api/engine/consolidation/prompts.py @@ -1,5 +1,7 @@ """Prompts for the consolidation engine.""" +import re + # Default mission when no bank-specific mission is set _DEFAULT_MISSION = "Track every detail: names, numbers, dates, places, and relationships. Prefer specifics over abstractions, never generalise." @@ -80,6 +82,28 @@ - Return {{"creates": [], "updates": [], "deletes": []}} if nothing durable is found.""" +_LONE_OPEN_BRACE = re.compile(r"(? str: + """Double any lone ``{`` / ``}`` so the text survives ``str.format`` untouched. + + The assembled prompt is later passed through ``str.format`` to substitute + real placeholders like ``{facts_text}``. Any literal braces in caller- + supplied text — e.g. a mission that happens to contain JSON examples or + config-shaped snippets — would otherwise be interpreted as format keys and + raise ``KeyError`` at consolidation time. + + Idempotent: text that already contains escaped ``{{`` / ``}}`` pairs is + left as-is. Only lone braces (not adjacent to another brace of the same + kind) are doubled. + """ + text = _LONE_OPEN_BRACE.sub("{{", text) + text = _LONE_CLOSE_BRACE.sub("}}", text) + return text + + def build_batch_consolidation_prompt( observations_mission: str | None = None, observation_capacity_note: str | None = None, @@ -90,11 +114,11 @@ def build_batch_consolidation_prompt( The mission defines *what* to track (customisable per bank). Processing rules and output format are always present regardless of mission. """ - mission = observations_mission or _DEFAULT_MISSION + mission = _escape_braces(observations_mission or _DEFAULT_MISSION) capacity_section = "" if observation_capacity_note: - capacity_section = f"\n\n## CAPACITY CONSTRAINT\n{observation_capacity_note}" + capacity_section = f"\n\n## CAPACITY CONSTRAINT\n{_escape_braces(observation_capacity_note)}" return ( "You are a memory consolidation system. Synthesize facts into observations " diff --git a/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py index 9bb6fdba0..0f02949d5 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py @@ -306,15 +306,19 @@ def __init__( self.api_key = "local" # Validate API key for cloud providers - if self.provider in ( - "openai", - "groq", - "minimax", - "deepseek", - "openrouter", - "zai", - "opencode-go", - ) and not self.api_key: + if ( + self.provider + in ( + "openai", + "groq", + "minimax", + "deepseek", + "openrouter", + "zai", + "opencode-go", + ) + and not self.api_key + ): raise ValueError(f"API key is required for {self.provider}") # Service tier configuration (from config, not env vars) diff --git a/hindsight-api-slim/tests/test_consolidation_prompt_brace_escape.py b/hindsight-api-slim/tests/test_consolidation_prompt_brace_escape.py new file mode 100644 index 000000000..e855b4364 --- /dev/null +++ b/hindsight-api-slim/tests/test_consolidation_prompt_brace_escape.py @@ -0,0 +1,116 @@ +""" +Tests for brace escaping in the consolidation prompt builder. + +The assembled prompt is later passed through ``str.format`` to substitute +real placeholders (``{facts_text}`` / ``{observations_text}``). Caller- +supplied text — ``observations_mission`` and ``observation_capacity_note`` — +must not crash the formatter when it happens to contain literal braces (e.g. +a JSON example). +""" + +import pytest + +from hindsight_api.engine.consolidation.prompts import ( + _escape_braces, + build_batch_consolidation_prompt, +) + + +def _render(prompt: str) -> str: + """Render the assembled prompt the way the consolidator does.""" + return prompt.format(facts_text="", observations_text="") + + +class TestEscapeBraces: + def test_lone_open_brace_doubled(self): + assert _escape_braces("{x}") == "{{x}}" + + def test_already_escaped_left_alone(self): + assert _escape_braces("{{x}}") == "{{x}}" + + def test_idempotent_under_repeat(self): + once = _escape_braces('{"dedup": true}') + twice = _escape_braces(once) + assert once == twice + + def test_no_braces_unchanged(self): + assert _escape_braces("just prose, no braces") == "just prose, no braces" + + def test_mixed_lone_and_escaped(self): + # Lone {x} should be escaped; existing {{y}} should stay. + assert _escape_braces("{x} and {{y}}") == "{{x}} and {{y}}" + + +class TestBuildBatchConsolidationPromptBraceSafety: + def test_default_mission_renders(self): + prompt = build_batch_consolidation_prompt() + rendered = _render(prompt) + assert "" in rendered + assert "" in rendered + + def test_mission_with_json_example_does_not_crash(self): + """Reproduces the failure mode where a mission containing literal + JSON braces was interpreted as a format placeholder.""" + mission = '{"dedup": true, "merge": true, "trend_tracking": false}' + prompt = build_batch_consolidation_prompt(observations_mission=mission) + rendered = _render(prompt) + # The original mission text appears verbatim in the rendered prompt. + assert mission in rendered + + def test_mission_with_multiple_brace_pairs(self): + mission = "Example schema: {a: 1} and counter-example: {b: 2}" + prompt = build_batch_consolidation_prompt(observations_mission=mission) + rendered = _render(prompt) + assert "{a: 1}" in rendered + assert "{b: 2}" in rendered + + def test_capacity_note_with_braces(self): + # observation_capacity_note is server-generated today, but the same + # escape contract applies in case future call sites widen the input. + note = "Use shape {limit, used}" + prompt = build_batch_consolidation_prompt( + observations_mission="m", + observation_capacity_note=note, + ) + rendered = _render(prompt) + assert "{limit, used}" in rendered + + def test_already_escaped_mission_renders_to_literal_braces(self): + """If a caller pre-escaped the mission (e.g. as a temporary data fix + applied before this code rolled out), the rendered prompt must still + contain the original single braces — not a double-escape artefact.""" + original = '{"dedup": true}' + pre_escaped = '{{"dedup": true}}' + prompt = build_batch_consolidation_prompt(observations_mission=pre_escaped) + rendered = _render(prompt) + assert original in rendered + assert "{{" not in rendered.split("## MISSION")[1].split("##")[0] + + def test_mission_without_braces_unchanged(self): + mission = "Track project deadlines and named contributors." + prompt = build_batch_consolidation_prompt(observations_mission=mission) + rendered = _render(prompt) + assert mission in rendered + + def test_unaffected_format_placeholders_still_substitute(self): + """The fix must not break the existing {facts_text} / {observations_text} + substitution path.""" + prompt = build_batch_consolidation_prompt(observations_mission="Note: {x: 1}") + rendered = prompt.format(facts_text="FACTS_HERE", observations_text="OBS_HERE") + assert "FACTS_HERE" in rendered + assert "OBS_HERE" in rendered + + @pytest.mark.parametrize( + "mission", + [ + "{single}", + "}}weird{{", + "trailing {", + "leading }", + "", + ], + ) + def test_assorted_mission_inputs_do_not_crash(self, mission): + prompt = build_batch_consolidation_prompt(observations_mission=mission) + # Just ensure format does not raise. + prompt.format(facts_text="f", observations_text="o") diff --git a/skills/hindsight-docs/references/developer/models.md b/skills/hindsight-docs/references/developer/models.md index b50e625a4..51278726a 100644 --- a/skills/hindsight-docs/references/developer/models.md +++ b/skills/hindsight-docs/references/developer/models.md @@ -30,6 +30,7 @@ Used for fact extraction, entity resolution, mental model consolidation, and ans - MiniMax - DeepSeek - z.ai +- opencode-go - Volcano Engine - OpenRouter - OpenAI Codex diff --git a/skills/hindsight-docs/references/faq.md b/skills/hindsight-docs/references/faq.md index 8b8b96a62..f4f8f2aca 100644 --- a/skills/hindsight-docs/references/faq.md +++ b/skills/hindsight-docs/references/faq.md @@ -76,6 +76,7 @@ Browse all supported integrations in the Integrations Hub. - MiniMax - DeepSeek - z.ai +- opencode-go - Volcano Engine - OpenRouter - OpenAI Codex