yc1838 commited on
Commit
52d9109
·
1 Parent(s): 7dec693

feat: add format-strip finalizer and fail_safe fallback to supervisor_best_answer

Browse files

- Add _strip_to_format to rewrite Final Answer candidates matching format constraints (first name, surname, single word, number only, comma-separated, etc.)
- supervisor_finalizer applies format-strip when question triggers format rules
- fail_safe falls back to supervisor_best_answer when model returns empty/missing Final Answer
- fail_safe uses descriptive default when no supervisor_best_answer available

Files changed (2) hide show
  1. src/lilith_agent/app.py +104 -6
  2. tests/test_graph.py +169 -0
src/lilith_agent/app.py CHANGED
@@ -139,6 +139,61 @@ def _is_placeholder_answer(answer: str) -> bool:
139
  return normalized in _PLACEHOLDER_ANSWERS
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  def _parse_supervisor_decision(content) -> dict:
143
  text = _message_text(content).strip()
144
  try:
@@ -634,10 +689,18 @@ def build_react_agent(cfg: Config):
634
  "search for `\"<video id>\" transcript`, `\"<video id>\"`, the exact video title, and distinctive quoted dialogue "
635
  "or on-screen phrases. Use search snippets, cached transcripts in Hugging Face Spaces/datasets, and reliable web "
636
  "pages as evidence. For visual questions, try `youtube_frame_at` only when a timestamp is needed; if video download "
637
- "is blocked, pivot to title/time/object searches and answer from the strongest available evidence."
638
- "directory to see what's actually there. User filename references may be casual or imprecise (e.g. `.lol` in chat is often laughter, not an extension).\n"
639
- "9. MATHEMATICAL PRECISION: If the question requires math, double-check your algebraic calculations carefully. If a specific decimal precision or rounding is asked for (e.g., 'to 2 decimal places', 'nearest tenth'), you MUST calculate precisely and round STRICTLY AT THE VERY END. Do NOT prematurely round intermediate numbers.\n"
640
- "10. FINAL ANSWER FORMAT: When you have the final answer, output ONLY the value itself. Do not say 'The answer is...', do not provide explanations in your final output. Just output the bare minimum exact string, number, or list."
 
 
 
 
 
 
 
 
641
  )
642
 
643
  if memory_context:
@@ -735,7 +798,29 @@ def build_react_agent(cfg: Config):
735
  )
736
  compacted = _compact_old_tool_messages(state["messages"], summarize_fn=summarize_fn)
737
  response = base_model.invoke([SystemMessage(sys_prompt)] + compacted)
738
- print(f"[fail_safe] produced content={_message_text(getattr(response, 'content', ''))[:240]!r}", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
739
  return {"messages": [response]}
740
 
741
  def supervisor_node(state):
@@ -809,6 +894,19 @@ def build_react_agent(cfg: Config):
809
  def supervisor_finalizer_node(state):
810
  best_answer = str(state.get("supervisor_best_answer", "") or "").strip()
811
  if best_answer and not _is_placeholder_answer(best_answer):
 
 
 
 
 
 
 
 
 
 
 
 
 
812
  print(f"[supervisor_finalizer] finalizing best={best_answer[:160]!r}", flush=True)
813
  return {"messages": [AIMessage(content=f"Final Answer: {best_answer}")]}
814
  guidance = str(state.get("supervisor_guidance", "") or "").strip()
@@ -958,7 +1056,7 @@ def build_react_agent(cfg: Config):
958
  _route_after_review,
959
  {"model": "model", "extract_memory": "extract_memory"},
960
  )
961
- graph.add_edge("supervisor_finalizer", "extract_memory")
962
  graph.add_edge("fail_safe", "extract_memory")
963
  graph.add_edge("extract_memory", END)
964
 
 
139
  return normalized in _PLACEHOLDER_ANSWERS
140
 
141
 
142
+ _FORMAT_TRIGGERS = re.compile(
143
+ r"\b(only the first name|first name only|give only the first|just the first name|"
144
+ r"surname|last name only|give only the surname|single word|one word|"
145
+ r"in alphabetical order|alphabetized|comma[-\s]separated|comma[-\s]delimited|"
146
+ r"without (?:any )?(?:punctuation|units|prefix|suffix|abbreviation)|"
147
+ r"no (?:units|prefix|suffix|abbreviation|punctuation)|"
148
+ r"number only|numeric only|digits only|integer only|"
149
+ r"give only|just give|give just|provide only)\b",
150
+ re.IGNORECASE,
151
+ )
152
+
153
+
154
+ def _needs_format_strip(question: str) -> bool:
155
+ return bool(_FORMAT_TRIGGERS.search(question or ""))
156
+
157
+
158
+ def _strip_to_format(question: str, candidate: str, cheap_model) -> str:
159
+ """Re-emit candidate trimmed to satisfy a stated output-format constraint.
160
+ Returns the original candidate on any failure."""
161
+ try:
162
+ prompt = (
163
+ "You are a strict format-extraction engine, not a chatbot. "
164
+ "Input: a benchmark question and a candidate answer. "
165
+ "Output: the candidate rewritten to satisfy the question's output-format constraint EXACTLY. "
166
+ "Rules:\n"
167
+ "- 'first name' / 'given name' => output ONE word (the given name only, drop surname).\n"
168
+ "- 'surname' / 'last name' => output ONE word (the surname only, drop given name).\n"
169
+ "- 'single word' / 'one word' => output ONE word, no punctuation, no quotes.\n"
170
+ "- 'number' / 'numeric' / 'digits only' / 'integer' => output digits only, no units, no commas, "
171
+ "unless the question explicitly asks for units.\n"
172
+ "- 'comma-separated' / 'comma-delimited' / 'alphabetized list' => output items joined by ', ' "
173
+ "with no prose, no leading/trailing punctuation.\n"
174
+ "- Strip leading prose: 'The answer is', 'He said', character/speaker names, quotation marks, "
175
+ "trailing periods unless the answer is a sentence.\n"
176
+ "- Preserve the candidate's facts; only adjust formatting/trimming. Do NOT change which entity "
177
+ "or value is named.\n"
178
+ "Output: the rewritten answer ONLY. No labels, no explanation, no quotes."
179
+ )
180
+ try:
181
+ invoker = cheap_model.bind(temperature=0)
182
+ except Exception:
183
+ invoker = cheap_model
184
+ resp = invoker.invoke([
185
+ SystemMessage(content=prompt),
186
+ HumanMessage(content=f"Question: {question}\nCandidate: {candidate}"),
187
+ ])
188
+ text = _message_text(getattr(resp, "content", "")).strip()
189
+ text = text.strip("\"' \n\t")
190
+ if text and not _is_placeholder_answer(text):
191
+ return text
192
+ except Exception as exc:
193
+ log.warning("[supervisor_finalizer] format strip failed: %s", exc)
194
+ return candidate
195
+
196
+
197
  def _parse_supervisor_decision(content) -> dict:
198
  text = _message_text(content).strip()
199
  try:
 
689
  "search for `\"<video id>\" transcript`, `\"<video id>\"`, the exact video title, and distinctive quoted dialogue "
690
  "or on-screen phrases. Use search snippets, cached transcripts in Hugging Face Spaces/datasets, and reliable web "
691
  "pages as evidence. For visual questions, try `youtube_frame_at` only when a timestamp is needed; if video download "
692
+ "is blocked, pivot to title/time/object searches and answer from the strongest available evidence.\n"
693
+ "9. FORMAT COMPLIANCE (BENCHMARK MODE): Before you emit 'Final Answer:', reread the question's last "
694
+ "sentence verbatim and treat it as a hard contract. You are a data-extraction engine, not a chatbot. "
695
+ "If the question says 'first name', output ONE word the given name only. If it says 'surname' or "
696
+ "'last name', output ONE word — the family name only. If it says 'single word' or 'one word', output "
697
+ "ONE word with no punctuation, no quotes, no speaker prefix. If it says 'comma-separated' or "
698
+ "'alphabetized list', output items joined by ', ' with no prose. If it asks for a number, output "
699
+ "digits only — no units, no commas — unless units are explicitly requested. Strip ALL leading prose "
700
+ "(e.g. 'The answer is', 'He said', character names, quotation marks). Constraint compliance beats "
701
+ "completeness: an over-long answer is wrong, not safer."
702
+ "10. MATHEMATICAL PRECISION: If the question requires math, double-check your algebraic calculations carefully. If a specific decimal precision or rounding is asked for (e.g., 'to 2 decimal places', 'nearest tenth'), you MUST calculate precisely and round STRICTLY AT THE VERY END. Do NOT prematurely round intermediate numbers.\n"
703
+ "11. FINAL ANSWER FORMAT: When you have the final answer, output ONLY the value itself. Do not say 'The answer is...', do not provide explanations in your final output. Just output the bare minimum exact string, number, or list."
704
  )
705
 
706
  if memory_context:
 
798
  )
799
  compacted = _compact_old_tool_messages(state["messages"], summarize_fn=summarize_fn)
800
  response = base_model.invoke([SystemMessage(sys_prompt)] + compacted)
801
+ content_text = _message_text(getattr(response, "content", "")).strip()
802
+
803
+ if not content_text or "final answer" not in content_text.lower():
804
+ best_answer = str(state.get("supervisor_best_answer", "") or "").strip()
805
+ if best_answer and not _is_placeholder_answer(best_answer):
806
+ response = AIMessage(content=f"Final Answer: {best_answer}")
807
+ content_text = response.content
808
+ print(
809
+ f"[fail_safe] empty/missing-final-answer; using supervisor_best_answer={best_answer[:160]!r}",
810
+ flush=True,
811
+ )
812
+ else:
813
+ fallback = (
814
+ "Final Answer: I cannot determine the answer from the available evidence."
815
+ )
816
+ response = AIMessage(content=fallback)
817
+ content_text = fallback
818
+ print(
819
+ "[fail_safe] empty/missing-final-answer; no supervisor_best_answer; using descriptive default",
820
+ flush=True,
821
+ )
822
+
823
+ print(f"[fail_safe] produced content={content_text[:240]!r}", flush=True)
824
  return {"messages": [response]}
825
 
826
  def supervisor_node(state):
 
894
  def supervisor_finalizer_node(state):
895
  best_answer = str(state.get("supervisor_best_answer", "") or "").strip()
896
  if best_answer and not _is_placeholder_answer(best_answer):
897
+ original_question = _initial_question_from_state(state)
898
+ if _needs_format_strip(original_question):
899
+ try:
900
+ cheap = get_cheap_model(cfg)
901
+ stripped = _strip_to_format(original_question, best_answer, cheap)
902
+ if stripped and stripped != best_answer:
903
+ print(
904
+ f"[supervisor_finalizer] format-strip {best_answer[:80]!r} -> {stripped[:80]!r}",
905
+ flush=True,
906
+ )
907
+ best_answer = stripped
908
+ except Exception as exc:
909
+ log.warning("[supervisor_finalizer] format strip skipped: %s", exc)
910
  print(f"[supervisor_finalizer] finalizing best={best_answer[:160]!r}", flush=True)
911
  return {"messages": [AIMessage(content=f"Final Answer: {best_answer}")]}
912
  guidance = str(state.get("supervisor_guidance", "") or "").strip()
 
1056
  _route_after_review,
1057
  {"model": "model", "extract_memory": "extract_memory"},
1058
  )
1059
+ graph.add_edge("supervisor_finalizer", "supervisor_review")
1060
  graph.add_edge("fail_safe", "extract_memory")
1061
  graph.add_edge("extract_memory", END)
1062
 
tests/test_graph.py CHANGED
@@ -596,6 +596,175 @@ def test_final_answer_gets_supervisor_review_and_can_be_returned_for_revision(mo
596
  assert all("wrong answer" != getattr(m, "content", "") for m in result["messages"][-1:])
597
 
598
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  def test_tool_node_invokes_tool_successfully():
600
  node = _build_tool_node([echo_tool])
601
  state = {"messages": [
 
596
  assert all("wrong answer" != getattr(m, "content", "") for m in result["messages"][-1:])
597
 
598
 
599
+ def test_fail_safe_falls_back_to_supervisor_best_answer_when_empty(monkeypatch, tmp_path):
600
+ """fail_safe model returning empty content must not propagate; supervisor_best_answer wins."""
601
+
602
+ class FakeBoundModel:
603
+ def __init__(self):
604
+ self.calls = 0
605
+
606
+ def invoke(self, messages):
607
+ self.calls += 1
608
+ return _ai_with_calls([
609
+ {
610
+ "id": f"call-{self.calls}",
611
+ "name": "echo_tool",
612
+ "args": {"text": str(self.calls)},
613
+ }
614
+ ])
615
+
616
+ class FakeStrongModel:
617
+ def __init__(self):
618
+ self.bound = FakeBoundModel()
619
+ self.fail_safe_calls = 0
620
+ self.supervisor_calls = 0
621
+
622
+ def bind_tools(self, tools):
623
+ return self.bound
624
+
625
+ def invoke(self, messages):
626
+ prompt = str(messages[0].content)
627
+ if "EMERGENCY OVERRIDE" in prompt:
628
+ self.fail_safe_calls += 1
629
+ return AIMessage(content="")
630
+ self.supervisor_calls += 1
631
+ return AIMessage(content='{"status":"nudge","best_answer":"backtick","guidance":"Stop and answer backtick."}')
632
+
633
+ strong = FakeStrongModel()
634
+ cfg = Config.from_env()
635
+ cfg.recursion_limit = 4
636
+ cfg.budget_hard_cap = 2
637
+ cfg.budget_warn_at = 99
638
+ cfg.compact_summarize = False
639
+ monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
640
+ monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
641
+ monkeypatch.setattr("lilith_agent.app.get_extra_strong_model", lambda cfg: strong)
642
+ monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
643
+ monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
644
+ monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
645
+
646
+ graph = build_react_agent(cfg)
647
+ result = graph.invoke(
648
+ {"messages": [HumanMessage(content="Question")], "iterations": 0, "todos": []},
649
+ {"configurable": {"thread_id": "fail-safe-best-answer-test"}},
650
+ )
651
+
652
+ last = result["messages"][-1]
653
+ assert last.content
654
+ assert "Final Answer:" in last.content
655
+ assert "backtick" in last.content
656
+
657
+
658
+ def test_fail_safe_never_returns_empty_answer_without_best_answer(monkeypatch, tmp_path):
659
+ """No supervisor_best_answer, fail_safe model empty: still produce non-empty descriptive Final Answer."""
660
+
661
+ class FakeBoundModel:
662
+ def __init__(self):
663
+ self.calls = 0
664
+
665
+ def invoke(self, messages):
666
+ self.calls += 1
667
+ return _ai_with_calls([
668
+ {
669
+ "id": f"call-{self.calls}",
670
+ "name": "echo_tool",
671
+ "args": {"text": str(self.calls)},
672
+ }
673
+ ])
674
+
675
+ class FakeStrongModel:
676
+ def __init__(self):
677
+ self.bound = FakeBoundModel()
678
+
679
+ def bind_tools(self, tools):
680
+ return self.bound
681
+
682
+ def invoke(self, messages):
683
+ return AIMessage(content="")
684
+
685
+ strong = FakeStrongModel()
686
+ cfg = Config.from_env()
687
+ cfg.recursion_limit = 4
688
+ cfg.budget_hard_cap = 2
689
+ cfg.budget_warn_at = 99
690
+ cfg.compact_summarize = False
691
+ monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
692
+ monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 99, raising=False)
693
+ monkeypatch.setattr("lilith_agent.app.get_extra_strong_model", lambda cfg: strong)
694
+ monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
695
+ monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
696
+ monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
697
+
698
+ graph = build_react_agent(cfg)
699
+ result = graph.invoke(
700
+ {"messages": [HumanMessage(content="Question")], "iterations": 0, "todos": []},
701
+ {"configurable": {"thread_id": "fail-safe-default-answer-test"}},
702
+ )
703
+
704
+ last = result["messages"][-1]
705
+ content = str(getattr(last, "content", ""))
706
+ assert content.strip(), "fail_safe must never propagate an empty answer"
707
+ assert "Final Answer:" in content
708
+
709
+
710
+ def test_supervisor_review_auto_approves_after_fail_safe(monkeypatch, tmp_path):
711
+ """fail_safe -> supervisor_review must auto-approve (no infinite loop back to model)."""
712
+
713
+ class FakeBoundModel:
714
+ def __init__(self):
715
+ self.calls = 0
716
+
717
+ def invoke(self, messages):
718
+ self.calls += 1
719
+ return _ai_with_calls([
720
+ {
721
+ "id": f"call-{self.calls}",
722
+ "name": "echo_tool",
723
+ "args": {"text": str(self.calls)},
724
+ }
725
+ ])
726
+
727
+ class FakeStrongModel:
728
+ def __init__(self):
729
+ self.bound = FakeBoundModel()
730
+ self.review_calls = 0
731
+
732
+ def bind_tools(self, tools):
733
+ return self.bound
734
+
735
+ def invoke(self, messages):
736
+ prompt = str(messages[0].content)
737
+ if "EMERGENCY OVERRIDE" in prompt:
738
+ return AIMessage(content="Final Answer: best effort")
739
+ if "FINAL ANSWER REVIEW" in prompt:
740
+ self.review_calls += 1
741
+ return AIMessage(content='{"status":"nudge","best_answer":"","guidance":"reject"}')
742
+ return AIMessage(content='{"status":"continue","best_answer":"","guidance":""}')
743
+
744
+ strong = FakeStrongModel()
745
+ cfg = Config.from_env()
746
+ cfg.recursion_limit = 4
747
+ cfg.budget_hard_cap = 2
748
+ cfg.budget_warn_at = 99
749
+ cfg.compact_summarize = False
750
+ monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
751
+ monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 99, raising=False)
752
+ monkeypatch.setattr("lilith_agent.app.get_extra_strong_model", lambda cfg: strong)
753
+ monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
754
+ monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
755
+ monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
756
+
757
+ graph = build_react_agent(cfg)
758
+ result = graph.invoke(
759
+ {"messages": [HumanMessage(content="Question")], "iterations": 0, "todos": []},
760
+ {"configurable": {"thread_id": "fail-safe-review-no-loop-test"}},
761
+ )
762
+
763
+ last = result["messages"][-1]
764
+ assert "Final Answer: best effort" in str(getattr(last, "content", ""))
765
+ assert strong.review_calls == 0
766
+
767
+
768
  def test_tool_node_invokes_tool_successfully():
769
  node = _build_tool_node([echo_tool])
770
  state = {"messages": [