Spaces:
Sleeping
Sleeping
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
- src/lilith_agent/app.py +104 -6
- 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 |
-
"
|
| 639 |
-
"
|
| 640 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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", "
|
| 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": [
|