yc1838
feat(agent): add open-ended research methodology
5f00812
Raw
History Blame Contribute Delete
46.2 kB
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool as tool_decorator
from lilith_agent.app import _build_tool_node, _cooldown_limit_for, _route_after_model, build_react_agent
from lilith_agent.config import Config
@tool_decorator
def echo_tool(text: str) -> str:
"""Echoes back the text."""
return f"echoed: {text}"
def _ai_with_calls(calls):
return AIMessage(content="", tool_calls=calls)
def test_router_goes_to_tools_when_tool_calls_present():
state = {"messages": [_ai_with_calls([{"id": "1", "name": "echo_tool", "args": {"text": "hi"}}])]}
assert _route_after_model(state) == "tools"
def test_router_ends_when_no_tool_calls():
state = {"messages": [AIMessage(content="done")]}
assert _route_after_model(state) == "extract_memory"
def test_graph_returns_fail_safe_answer_when_hard_cap_hits_near_recursion_limit(monkeypatch, tmp_path, capsys):
class FakeModel:
def __init__(self):
self.calls = 0
def bind_tools(self, tools, **kwargs):
return self
def invoke(self, messages):
if "SYSTEM EMERGENCY OVERRIDE" in str(messages[0].content):
return AIMessage(content="Final Answer: best effort answer")
self.calls += 1
return _ai_with_calls([
{
"id": f"call-{self.calls}",
"name": "echo_tool",
"args": {"text": str(self.calls)},
}
])
fake_model = FakeModel()
cfg = Config.from_env()
cfg.recursion_limit = 4
cfg.budget_hard_cap = 2
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg: fake_model)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: fake_model)
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="answer this")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "hard-cap-test"}},
)
captured = capsys.readouterr().out
assert "[route] recursion threshold reached" in captured
assert "[fail_safe] emergency override" in captured
assert result["messages"][-1].content == "Final Answer: best effort answer"
def test_build_react_agent_prints_effective_recursion_limit(monkeypatch, tmp_path, capsys):
class FakeModel:
def bind_tools(self, tools, **kwargs):
return self
cfg = Config.from_env()
cfg.recursion_limit = 50
cfg.budget_hard_cap = 25
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg: FakeModel())
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: FakeModel())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
build_react_agent(cfg)
captured = capsys.readouterr().out
assert "[graph] effective_recursion_limit=79 logical_recursion_limit=50 budget_hard_cap=25 headroom=4" in captured
def test_model_prompt_includes_youtube_fallback_strategy(monkeypatch, tmp_path):
class FakeModel:
def __init__(self):
self.system_prompt = ""
def bind_tools(self, tools, **kwargs):
return self
def invoke(self, messages):
if not self.system_prompt:
self.system_prompt = str(messages[0].content)
return AIMessage(content="Final Answer: inspected")
fake_model = FakeModel()
cfg = Config.from_env()
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg: fake_model)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: fake_model)
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
graph.invoke(
{"messages": [HumanMessage(content="What happens in https://www.youtube.com/watch?v=abcdefghijk?")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "youtube-fallback-prompt-test"}},
)
prompt = fake_model.system_prompt.lower()
assert "youtube fallback strategy" in prompt
assert "video id" in prompt
assert "transcript" in prompt
assert "do not repeatedly retry" in prompt
def test_fail_safe_uses_unbound_model_to_prevent_more_tool_calls(monkeypatch, tmp_path):
class FakeBoundModel:
def __init__(self):
self.calls = 0
def invoke(self, messages):
self.calls += 1
return _ai_with_calls([
{
"id": f"bound-call-{self.calls}",
"name": "echo_tool",
"args": {"text": str(self.calls)},
}
])
class FakeModel:
def __init__(self):
self.bound = FakeBoundModel()
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
return AIMessage(content="Final Answer: unbound best effort")
fake_model = FakeModel()
cfg = Config.from_env()
cfg.recursion_limit = 4
cfg.budget_hard_cap = 1
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg: fake_model)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: fake_model)
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="answer this")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "unbound-fail-safe-test"}},
)
assert result["messages"][-1].content == "Final Answer: unbound best effort"
assert not getattr(result["messages"][-1], "tool_calls", None)
def test_fail_safe_prompt_reinforces_original_question_contract(monkeypatch, tmp_path):
class FakeBoundModel:
def invoke(self, messages):
return _ai_with_calls([
{
"id": "bound-call",
"name": "echo_tool",
"args": {"text": "intermediate"},
}
])
class FakeModel:
def __init__(self):
self.bound = FakeBoundModel()
self.fail_safe_prompt = ""
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
self.fail_safe_prompt = str(messages[0].content)
return AIMessage(content="Final Answer: best effort")
fake_model = FakeModel()
cfg = Config.from_env()
cfg.recursion_limit = 4
cfg.budget_hard_cap = 1
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg: fake_model)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: fake_model)
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
graph.invoke(
{"messages": [HumanMessage(content="What country corresponds to this capital?")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "fail-safe-contract-prompt-test"}},
)
prompt = fake_model.fail_safe_prompt.lower()
assert "original question" in prompt
assert "not an intermediate" in prompt
assert "bare final answer" in prompt
def test_supervisor_nudges_agent_to_answer_when_evidence_is_enough(monkeypatch, tmp_path):
class FakeBoundModel:
def __init__(self):
self.calls = 0
def invoke(self, messages):
self.calls += 1
if any("SUPERVISOR" in str(getattr(m, "content", "")) for m in messages):
return AIMessage(content="Final Answer: backtick")
return _ai_with_calls([
{
"id": f"call-{self.calls}",
"name": "echo_tool",
"args": {"text": "Add `: 'For penguins\\n'"},
}
])
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
self.supervisor_calls = 0
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
self.supervisor_calls += 1
return AIMessage(content='{"status":"nudge","best_answer":"backtick","guidance":"You have verified the answer. Stop and answer backtick."}')
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 10
cfg.budget_hard_cap = 99
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="Unlambda question")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "supervisor-nudge-test"}},
)
assert result["messages"][-1].content == "Final Answer: backtick"
assert strong.supervisor_calls == 1
assert strong.bound.calls == 2
def test_supervisor_uses_extra_strong_model_not_cheap_model(monkeypatch, tmp_path):
class FakeBoundModel:
def __init__(self):
self.calls = 0
def invoke(self, messages):
self.calls += 1
if any("SUPERVISOR" in str(getattr(m, "content", "")) for m in messages):
return AIMessage(content="Final Answer: backtick")
return _ai_with_calls([
{
"id": f"call-{self.calls}",
"name": "echo_tool",
"args": {"text": "evidence"},
}
])
class FakeExtraStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
self.supervisor_calls = 0
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
self.supervisor_calls += 1
return AIMessage(content='{"status":"nudge","best_answer":"backtick","guidance":"Stop and answer backtick."}')
strong = FakeExtraStrongModel()
def cheap_should_not_be_used(cfg):
raise AssertionError("supervisor should use extra strong model, not cheap model")
cfg = Config.from_env()
cfg.recursion_limit = 10
cfg.budget_hard_cap = 99
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", cheap_should_not_be_used)
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="Unlambda question")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "supervisor-extra-strong-test"}},
)
assert result["messages"][-1].content == "Final Answer: backtick"
assert strong.supervisor_calls == 1
def test_supervisor_finalizer_prompt_reinforces_original_question_contract(monkeypatch, tmp_path):
class FakeBoundModel:
def invoke(self, messages):
return _ai_with_calls([
{
"id": "call",
"name": "echo_tool",
"args": {"text": "evidence"},
}
])
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
self.finalizer_prompt = ""
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
prompt = str(messages[0].content)
if "SUPERVISOR FINALIZER" in prompt:
self.finalizer_prompt = prompt
return AIMessage(content="Final Answer: final")
return AIMessage(content='{"status":"finalize","best_answer":"","guidance":"Existing evidence is enough."}')
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 10
cfg.budget_hard_cap = 99
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
graph.invoke(
{"messages": [HumanMessage(content="What final entity answers the original question?")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "supervisor-finalizer-contract-prompt-test"}},
)
prompt = strong.finalizer_prompt.lower()
assert "original question" in prompt
assert "not an intermediate" in prompt
assert "bare final answer" in prompt
def test_supervisor_finalizer_rejects_unknown_best_answer_and_forces_best_guess(monkeypatch, tmp_path):
class FakeBoundModel:
def invoke(self, messages):
return _ai_with_calls([
{
"id": "call",
"name": "echo_tool",
"args": {"text": "evidence"},
}
])
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
self.finalizer_calls = 0
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
prompt = str(messages[0].content)
if "SUPERVISOR FINALIZER" in prompt:
self.finalizer_calls += 1
return AIMessage(content="Final Answer: best guess")
return AIMessage(content='{"status":"finalize","best_answer":"unknown","guidance":"Looping; make a best guess."}')
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 10
cfg.budget_hard_cap = 99
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="Unlambda question")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "supervisor-unknown-best-answer-test"}},
)
assert result["messages"][-1].content == "Final Answer: best guess"
assert strong.finalizer_calls == 1
def test_supervisor_finalizes_even_with_placeholder_if_requested(monkeypatch, tmp_path):
class FakeBoundModel:
def __init__(self):
self.calls = 0
def invoke(self, messages):
self.calls += 1
return _ai_with_calls([
{
"id": f"call-{self.calls}",
"name": "echo_tool",
"args": {"text": "partial evidence"},
}
])
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
self.supervisor_calls = 0
self.finalizer_calls = 0
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
prompt = str(messages[0].content)
if "SUPERVISOR FINALIZER" in prompt:
self.finalizer_calls += 1
return AIMessage(content="Final Answer: finalizer output")
self.supervisor_calls += 1
if self.supervisor_calls == 1:
return AIMessage(content='{"status":"nudge","best_answer":"","guidance":"Use the evidence to make a best guess."}')
# On second call, it asks to finalize but with a placeholder answer. The code should allow finalization.
return AIMessage(content='{"status":"finalize","best_answer":"Unknown","guidance":"You were already nudged. Provide your final answer based on the best available information."}')
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 12
cfg.budget_hard_cap = 99
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="Question requiring a concrete answer")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "supervisor-finalize-after-nudge-test"}},
)
assert result["messages"][-1].content == "Final Answer: finalizer output"
assert strong.finalizer_calls == 1
assert strong.supervisor_calls == 2
def test_supervisor_finalizer_forbids_tool_calls_to_prevent_markup_leak(monkeypatch, tmp_path):
# DeepSeek thinking-off still emits raw tool-call markup when invoked unbound
# against a tool-laden history. The finalizer must bind tools with
# tool_choice="none" so the model is forced to answer in plain text.
dsml_leak = '<||DSML||tool_calls><||DSML||invoke name="web_search"></||DSML||invoke>'
class FakeBoundModel:
def invoke(self, messages):
return _ai_with_calls([
{"id": "call", "name": "echo_tool", "args": {"text": "evidence"}}
])
class FakeFinalizerBound:
def invoke(self, messages):
return AIMessage(content="Final Answer: synthesized report")
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
self.finalizer_tool_choice = None
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
self.finalizer_tool_choice = "none"
return FakeFinalizerBound()
return self.bound
def invoke(self, messages):
prompt = str(messages[0].content)
if "SUPERVISOR FINALIZER" in prompt:
# Reached only if the finalizer wrongly invokes the unbound model.
return AIMessage(content=dsml_leak)
return AIMessage(content='{"status":"finalize","best_answer":"","guidance":"Evidence is enough."}')
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 10
cfg.budget_hard_cap = 99
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="Research the competitor landscape.")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "supervisor-finalizer-markup-leak-test"}},
)
final = result["messages"][-1].content
assert "DSML" not in final
assert final == "Final Answer: synthesized report"
assert strong.finalizer_tool_choice == "none"
def test_supervisor_forces_finalize_after_max_nudges(monkeypatch, tmp_path):
class FakeBoundModel:
def __init__(self):
self.calls = 0
def invoke(self, messages):
self.calls += 1
return _ai_with_calls([
{
"id": f"call-{self.calls}",
"name": "echo_tool",
"args": {"text": "partial evidence"},
}
])
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
self.supervisor_calls = 0
self.finalizer_calls = 0
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
prompt = str(messages[0].content)
if "SUPERVISOR FINALIZER" in prompt:
self.finalizer_calls += 1
return AIMessage(content="Final Answer: max nudges forced this")
self.supervisor_calls += 1
return AIMessage(content='{"status":"nudge","best_answer":"concrete candidate","guidance":"Check one more constraint before final answer."}')
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 20
cfg.budget_hard_cap = 99
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MAX_NUDGES", 5, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="Question requiring more checking")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "supervisor-max-nudges-test"}},
)
assert strong.finalizer_calls == 0
assert result["messages"][-1].content == "Final Answer: concrete candidate"
assert strong.supervisor_calls == 5
assert result["supervisor_decision"] == "finalize"
def test_final_answer_gets_supervisor_review_and_can_be_returned_for_revision(monkeypatch, tmp_path):
class FakeBoundModel:
def __init__(self):
self.calls = 0
def invoke(self, messages):
self.calls += 1
if any("FINAL ANSWER REVIEW FAILED" in str(getattr(m, "content", "")) for m in messages):
return AIMessage(content="Final Answer: corrected answer")
return AIMessage(content="Final Answer: wrong answer")
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
self.review_calls = 0
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
prompt = str(messages[0].content)
if "FINAL ANSWER REVIEW" in prompt:
self.review_calls += 1
if self.review_calls == 1:
return AIMessage(content='{"status":"nudge","best_answer":"","guidance":"The proposed answer violates a constraint; fix it."}')
return AIMessage(content='{"status":"finalize","best_answer":"corrected answer","guidance":"Approved."}')
return AIMessage(content='{"status":"continue","best_answer":"","guidance":""}')
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 10
cfg.budget_hard_cap = 99
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="Question with constraint")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "final-answer-review-revision-test"}},
)
assert strong.review_calls == 2
assert "Final Answer: corrected answer" in result["messages"][-1].content
assert all("wrong answer" != getattr(m, "content", "") for m in result["messages"][-1:])
def test_fail_safe_falls_back_to_supervisor_best_answer_when_empty(monkeypatch, tmp_path):
"""fail_safe model returning empty content must not propagate; supervisor_best_answer wins."""
class FakeBoundModel:
def __init__(self):
self.calls = 0
def invoke(self, messages):
self.calls += 1
return _ai_with_calls([
{
"id": f"call-{self.calls}",
"name": "echo_tool",
"args": {"text": str(self.calls)},
}
])
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
self.fail_safe_calls = 0
self.supervisor_calls = 0
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
prompt = str(messages[0].content)
if "EMERGENCY OVERRIDE" in prompt:
self.fail_safe_calls += 1
return AIMessage(content="")
self.supervisor_calls += 1
return AIMessage(content='{"status":"nudge","best_answer":"backtick","guidance":"Stop and answer backtick."}')
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 4
cfg.budget_hard_cap = 2
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="Question")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "fail-safe-best-answer-test"}},
)
last = result["messages"][-1]
assert last.content
assert "Final Answer:" in last.content
assert "backtick" in last.content
def test_fail_safe_never_returns_empty_answer_without_best_answer(monkeypatch, tmp_path):
"""No supervisor_best_answer, fail_safe model empty: still produce non-empty descriptive Final Answer."""
class FakeBoundModel:
def __init__(self):
self.calls = 0
def invoke(self, messages):
self.calls += 1
return _ai_with_calls([
{
"id": f"call-{self.calls}",
"name": "echo_tool",
"args": {"text": str(self.calls)},
}
])
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
return AIMessage(content="")
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 4
cfg.budget_hard_cap = 2
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 99, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="Question")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "fail-safe-default-answer-test"}},
)
last = result["messages"][-1]
content = str(getattr(last, "content", ""))
assert content.strip(), "fail_safe must never propagate an empty answer"
assert "Final Answer:" in content
def test_supervisor_review_auto_approves_after_fail_safe(monkeypatch, tmp_path):
"""fail_safe -> supervisor_review must auto-approve (no infinite loop back to model)."""
class FakeBoundModel:
def __init__(self):
self.calls = 0
def invoke(self, messages):
self.calls += 1
return _ai_with_calls([
{
"id": f"call-{self.calls}",
"name": "echo_tool",
"args": {"text": str(self.calls)},
}
])
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
self.review_calls = 0
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
prompt = str(messages[0].content)
if "EMERGENCY OVERRIDE" in prompt:
return AIMessage(content="Final Answer: best effort")
if "FINAL ANSWER REVIEW" in prompt:
self.review_calls += 1
return AIMessage(content='{"status":"nudge","best_answer":"","guidance":"reject"}')
return AIMessage(content='{"status":"continue","best_answer":"","guidance":""}')
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 4
cfg.budget_hard_cap = 2
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 99, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="Question")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "fail-safe-review-no-loop-test"}},
)
last = result["messages"][-1]
assert "Final Answer: best effort" in str(getattr(last, "content", ""))
assert strong.review_calls == 0
def test_tool_node_invokes_tool_successfully():
node = _build_tool_node([echo_tool])
state = {"messages": [
HumanMessage(content="say hi"),
_ai_with_calls([{"id": "1", "name": "echo_tool", "args": {"text": "hi"}}]),
]}
out = node(state)
assert len(out["messages"]) == 1
msg = out["messages"][0]
assert isinstance(msg, ToolMessage)
assert msg.tool_call_id == "1"
assert "echoed: hi" in msg.content
def test_supervisor_finalizes_when_agent_ignores_prior_nudge(monkeypatch, tmp_path):
class FakeBoundModel:
def __init__(self):
self.calls = 0
def invoke(self, messages):
self.calls += 1
sup_count = sum(
1 for m in messages
if "SUPERVISOR:" in str(getattr(m, "content", ""))
)
if sup_count >= 2:
return AIMessage(content="Final Answer: backtick")
return _ai_with_calls([
{
"id": f"call-{self.calls}",
"name": "echo_tool",
"args": {"text": str(self.calls)},
}
])
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
self.supervisor_calls = 0
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
self.supervisor_calls += 1
return AIMessage(content='{"status":"nudge","best_answer":"backtick","guidance":"Stop. Existing evidence supports backtick."}')
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 12
cfg.budget_hard_cap = 99
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="Unlambda question")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "supervisor-finalize-test"}},
)
assert result["messages"][-1].content == "Final Answer: backtick"
assert strong.supervisor_calls == 2
assert strong.bound.calls == 3
def test_supervisor_overhead_leaves_room_for_hard_cap_fail_safe(monkeypatch, tmp_path):
class FakeBoundModel:
def __init__(self):
self.calls = 0
def invoke(self, messages):
self.calls += 1
return _ai_with_calls([
{
"id": f"call-{self.calls}",
"name": "echo_tool",
"args": {"text": str(self.calls)},
}
])
class FakeStrongModel:
def __init__(self):
self.bound = FakeBoundModel()
def bind_tools(self, tools, **kwargs):
if kwargs.get("tool_choice") == "none":
return self
return self.bound
def invoke(self, messages):
return AIMessage(content="Final Answer: hard cap fallback")
class FakeSupervisorModel:
def invoke(self, messages):
return AIMessage(content='{"status":"continue"}')
strong = FakeStrongModel()
cfg = Config.from_env()
cfg.recursion_limit = 8
cfg.budget_hard_cap = 5
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg, thinking=True: strong)
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: FakeSupervisorModel())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="force hard cap")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "supervisor-hard-cap-headroom-test"}},
)
assert result["messages"][-1].content == "Final Answer: hard cap fallback"
def test_supervisor_overhead_leaves_room_for_iteration_fail_safe(monkeypatch, tmp_path):
class FakeBoundModel:
def invoke(self, messages):
return _ai_with_calls([
{
"id": "call",
"name": "echo_tool",
"args": {"text": "loop"},
}
])
class FakeStrongModel:
def bind_tools(self, tools):
return FakeBoundModel()
def invoke(self, messages):
return AIMessage(content="Final Answer: iteration fallback")
class FakeSupervisorModel:
def invoke(self, messages):
return AIMessage(content='{"status":"continue"}')
cfg = Config.from_env()
cfg.recursion_limit = 5
cfg.budget_hard_cap = 99
cfg.budget_warn_at = 99
cfg.compact_summarize = False
monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
monkeypatch.setattr("lilith_agent.app.get_strong_model", lambda cfg: FakeStrongModel())
monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: FakeSupervisorModel())
monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
graph = build_react_agent(cfg)
result = graph.invoke(
{"messages": [HumanMessage(content="force iteration cap")], "iterations": 0, "todos": []},
{"configurable": {"thread_id": "supervisor-iteration-headroom-test"}},
)
assert result["messages"][-1].content == "Final Answer: iteration fallback"
def test_tool_node_invokes_tool_and_returns_tool_message():
node = _build_tool_node([echo_tool])
state = {"messages": [
HumanMessage(content="say hi"),
_ai_with_calls([{"id": "1", "name": "echo_tool", "args": {"text": "hi"}}]),
]}
out = node(state)
assert len(out["messages"]) == 1
msg = out["messages"][0]
assert isinstance(msg, ToolMessage)
assert msg.tool_call_id == "1"
assert "echoed: hi" in msg.content
def test_tool_node_dedups_repeat_tool_call_without_invoking():
calls = 0
@tool_decorator
def counting_tool(x: str) -> str:
"""Counting tool."""
nonlocal calls
calls += 1
return f"ran {calls}"
node = _build_tool_node([counting_tool])
# History: earlier AI message already called counting_tool(x="a")
prior_call = {"id": "old", "name": "counting_tool", "args": {"x": "a"}}
prior_ai = _ai_with_calls([prior_call])
prior_result = ToolMessage(tool_call_id="old", name="counting_tool", content="ran 0")
# Now a new AI message asks for the same tool with the same args.
new_call = {"id": "new", "name": "counting_tool", "args": {"x": "a"}}
state = {"messages": [
HumanMessage(content="go"),
prior_ai,
prior_result,
_ai_with_calls([new_call]),
]}
out = node(state)
assert calls == 0, "deduped call must not invoke the tool again"
msg = out["messages"][0]
assert isinstance(msg, ToolMessage)
assert msg.tool_call_id == "new"
assert "already called" in msg.content.lower()
def test_tool_node_prints_semantic_dedup_queries(capsys):
node = _build_tool_node([echo_tool], semantic_dedup_threshold=0.5)
prior_call = {"id": "old", "name": "web_search", "args": {"query": "apollo moon landing transcript"}}
new_call = {"id": "new", "name": "web_search", "args": {"query": "moon landing apollo transcript"}}
state = {"messages": [
HumanMessage(content="go"),
_ai_with_calls([prior_call]),
ToolMessage(tool_call_id="old", name="web_search", content="done"),
_ai_with_calls([new_call]),
]}
out = node(state)
printed = capsys.readouterr().out
assert "[tools] semantic_dedup score=1.00 tool=web_search" in printed
assert "query='moon landing apollo transcript'" in printed
assert "prior_query='apollo moon landing transcript'" in printed
assert "REDUNDANT SEARCH PATH" in out["messages"][0].content
def test_tool_node_handles_unknown_tool_name():
node = _build_tool_node([echo_tool])
state = {"messages": [_ai_with_calls([{"id": "1", "name": "ghost", "args": {}}])]}
out = node(state)
msg = out["messages"][0]
assert isinstance(msg, ToolMessage)
assert "unknown tool" in msg.content.lower()
def test_dedup_does_not_emit_warning_level_logs(caplog):
"""Routine dedup fires on many turns; WARNING floods stderr during normal runs.
Regression guard: `[dedup]`, `[semantic_dedup]`, `[loop_breaker]` stay at INFO."""
import logging
node = _build_tool_node([echo_tool])
prior_call = {"id": "old", "name": "echo_tool", "args": {"text": "x"}}
state = {"messages": [
HumanMessage(content="go"),
_ai_with_calls([prior_call]),
ToolMessage(tool_call_id="old", name="echo_tool", content="done"),
_ai_with_calls([{"id": "new", "name": "echo_tool", "args": {"text": "x"}}]),
]}
with caplog.at_level(logging.DEBUG, logger="lilith_agent.app"):
node(state)
for rec in caplog.records:
if "[dedup]" in rec.getMessage() or "[semantic_dedup]" in rec.getMessage() or "[loop_breaker]" in rec.getMessage():
assert rec.levelno < logging.WARNING, f"routine guard log at {rec.levelname}: {rec.message}"
def test_cooldown_limit_for_known_tool_is_positive_int():
"""Each tool must declare a positive cooldown limit. Regression guard
against the `3 if name == 'web_search' else 3` no-op ternary."""
limit = _cooldown_limit_for("web_search")
assert isinstance(limit, int) and limit > 0
assert _cooldown_limit_for("fetch_url") == _cooldown_limit_for("web_search")
def test_tool_node_catches_tool_exceptions_and_feeds_back():
@tool_decorator
def boom_tool(x: str) -> str:
"""Always raises."""
raise RuntimeError("kaboom")
node = _build_tool_node([boom_tool])
state = {"messages": [_ai_with_calls([{"id": "1", "name": "boom_tool", "args": {"x": "y"}}])]}
out = node(state)
msg = out["messages"][0]
assert isinstance(msg, ToolMessage)
assert "kaboom" in msg.content
@tool_decorator
def todo_sentinel_tool(action: str) -> str:
"""Returns todo sentinel output."""
if action == "write":
return "SET_TODOS: ['first', 'second']"
return "DONE_TODO: 0"
def test_tool_node_consumes_todo_sentinels_into_state():
node = _build_tool_node([todo_sentinel_tool])
write_out = node({
"messages": [_ai_with_calls([{"id": "1", "name": "todo_sentinel_tool", "args": {"action": "write"}}])],
"todos": [],
})
assert write_out["todos"] == ["first", "second"]
done_out = node({
"messages": [_ai_with_calls([{"id": "2", "name": "todo_sentinel_tool", "args": {"action": "done"}}])],
"todos": write_out["todos"],
})
assert done_out["todos"] == ["second"]