yc1838 commited on
Commit
bad5fbe
·
1 Parent(s): 335df66

update supervisor nudging logic and fix tests

Browse files
Files changed (2) hide show
  1. src/lilith_agent/app.py +10 -4
  2. tests/test_graph.py +14 -13
src/lilith_agent/app.py CHANGED
@@ -80,6 +80,7 @@ _FAIL_SAFE_RECURSION_HEADROOM = 4
80
  _SUPERVISOR_MIN_TOOL_CALLS = 5
81
  _SUPERVISOR_RECENT_MESSAGES = 12
82
  _SUPERVISOR_REVIEW_MAX = 3
 
83
 
84
 
85
  _RESPONSE_METADATA_NOISE_KEYS = frozenset({
@@ -828,6 +829,12 @@ def build_react_agent(cfg: Config):
828
  return {"messages": [response]}
829
 
830
  def supervisor_node(state):
 
 
 
 
 
 
831
  tool_calls_this_turn = _count_tool_calls_since_last_human(state["messages"])
832
  if supervisor_model is None or tool_calls_this_turn < _SUPERVISOR_MIN_TOOL_CALLS:
833
  return {
@@ -847,8 +854,9 @@ def build_react_agent(cfg: Config):
847
  "Return ONLY JSON with keys status, best_answer, guidance. "
848
  "status must be continue, nudge, or finalize. Use nudge when evidence likely supports "
849
  "an answer but one more agent turn is acceptable. Use finalize only when a concrete "
850
- "submit-ready answer is available or the evidence is conclusive. A prior nudge alone is "
851
- "not a reason to finalize. Never put placeholder values like unknown, n/a, none, or not "
 
852
  "sure in best_answer. If no concrete submit-ready answer is available, set best_answer "
853
  "to an empty string and use guidance to force the agent to make its best guess."
854
  )
@@ -863,8 +871,6 @@ def build_react_agent(cfg: Config):
863
  if _is_placeholder_answer(best_answer):
864
  print(f"[supervisor] discarded placeholder best_answer={best_answer[:80]!r}", flush=True)
865
  best_answer = ""
866
- if status == "finalize" and state.get("supervisor_nudges", 0) > 0 and not best_answer:
867
- status = "nudge"
868
  log.info(
869
  "[supervisor] status=%s best=%r guidance=%r",
870
  status,
 
80
  _SUPERVISOR_MIN_TOOL_CALLS = 5
81
  _SUPERVISOR_RECENT_MESSAGES = 12
82
  _SUPERVISOR_REVIEW_MAX = 3
83
+ _SUPERVISOR_MAX_NUDGES = 5
84
 
85
 
86
  _RESPONSE_METADATA_NOISE_KEYS = frozenset({
 
829
  return {"messages": [response]}
830
 
831
  def supervisor_node(state):
832
+ if state.get("supervisor_nudges", 0) >= _SUPERVISOR_MAX_NUDGES:
833
+ return {
834
+ "supervisor_decision": "finalize",
835
+ "supervisor_best_answer": state.get("supervisor_best_answer", ""),
836
+ "supervisor_guidance": "Nudge cap reached. Commit to best available answer.",
837
+ }
838
  tool_calls_this_turn = _count_tool_calls_since_last_human(state["messages"])
839
  if supervisor_model is None or tool_calls_this_turn < _SUPERVISOR_MIN_TOOL_CALLS:
840
  return {
 
854
  "Return ONLY JSON with keys status, best_answer, guidance. "
855
  "status must be continue, nudge, or finalize. Use nudge when evidence likely supports "
856
  "an answer but one more agent turn is acceptable. Use finalize only when a concrete "
857
+ "submit-ready answer is available or the evidence is conclusive. If you have already nudged "
858
+ "five times and the agent has not improved, use finalize to force termination do not nudge "
859
+ "repeatedly. Never put placeholder values like unknown, n/a, none, or not "
860
  "sure in best_answer. If no concrete submit-ready answer is available, set best_answer "
861
  "to an empty string and use guidance to force the agent to make its best guess."
862
  )
 
871
  if _is_placeholder_answer(best_answer):
872
  print(f"[supervisor] discarded placeholder best_answer={best_answer[:80]!r}", flush=True)
873
  best_answer = ""
 
 
874
  log.info(
875
  "[supervisor] status=%s best=%r guidance=%r",
876
  status,
tests/test_graph.py CHANGED
@@ -428,15 +428,13 @@ def test_supervisor_finalizer_rejects_unknown_best_answer_and_forces_best_guess(
428
  assert strong.finalizer_calls == 1
429
 
430
 
431
- def test_supervisor_does_not_finalize_placeholder_after_prior_nudge(monkeypatch, tmp_path):
432
  class FakeBoundModel:
433
  def __init__(self):
434
  self.calls = 0
435
 
436
  def invoke(self, messages):
437
  self.calls += 1
438
- if self.calls >= 3 and any("SUPERVISOR" in str(getattr(m, "content", "")) for m in messages):
439
- return AIMessage(content="Final Answer: recovered guess")
440
  return _ai_with_calls([
441
  {
442
  "id": f"call-{self.calls}",
@@ -458,10 +456,11 @@ def test_supervisor_does_not_finalize_placeholder_after_prior_nudge(monkeypatch,
458
  prompt = str(messages[0].content)
459
  if "SUPERVISOR FINALIZER" in prompt:
460
  self.finalizer_calls += 1
461
- return AIMessage(content="Final Answer: premature finalizer")
462
  self.supervisor_calls += 1
463
  if self.supervisor_calls == 1:
464
  return AIMessage(content='{"status":"nudge","best_answer":"","guidance":"Use the evidence to make a best guess."}')
 
465
  return AIMessage(content='{"status":"finalize","best_answer":"Unknown","guidance":"You were already nudged. Provide your final answer based on the best available information."}')
466
 
467
  strong = FakeStrongModel()
@@ -480,15 +479,15 @@ def test_supervisor_does_not_finalize_placeholder_after_prior_nudge(monkeypatch,
480
  graph = build_react_agent(cfg)
481
  result = graph.invoke(
482
  {"messages": [HumanMessage(content="Question requiring a concrete answer")], "iterations": 0, "todos": []},
483
- {"configurable": {"thread_id": "supervisor-placeholder-after-nudge-test"}},
484
  )
485
 
486
- assert result["messages"][-1].content == "Final Answer: recovered guess"
487
- assert strong.finalizer_calls == 0
488
  assert strong.supervisor_calls == 2
489
 
490
 
491
- def test_supervisor_does_not_auto_finalize_concrete_best_answer_after_prior_nudge(monkeypatch, tmp_path):
492
  class FakeBoundModel:
493
  def __init__(self):
494
  self.calls = 0
@@ -516,18 +515,19 @@ def test_supervisor_does_not_auto_finalize_concrete_best_answer_after_prior_nudg
516
  prompt = str(messages[0].content)
517
  if "SUPERVISOR FINALIZER" in prompt:
518
  self.finalizer_calls += 1
519
- return AIMessage(content="Final Answer: should not happen")
520
  self.supervisor_calls += 1
521
  return AIMessage(content='{"status":"nudge","best_answer":"concrete candidate","guidance":"Check one more constraint before final answer."}')
522
 
523
  strong = FakeStrongModel()
524
  cfg = Config.from_env()
525
- cfg.recursion_limit = 10
526
  cfg.budget_hard_cap = 99
527
  cfg.budget_warn_at = 99
528
  cfg.compact_summarize = False
529
  monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
530
  monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
 
531
  monkeypatch.setattr("lilith_agent.app.get_extra_strong_model", lambda cfg: strong)
532
  monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
533
  monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
@@ -536,12 +536,13 @@ def test_supervisor_does_not_auto_finalize_concrete_best_answer_after_prior_nudg
536
  graph = build_react_agent(cfg)
537
  result = graph.invoke(
538
  {"messages": [HumanMessage(content="Question requiring more checking")], "iterations": 0, "todos": []},
539
- {"configurable": {"thread_id": "supervisor-no-auto-finalize-after-nudge-test"}},
540
  )
541
 
542
  assert strong.finalizer_calls == 0
543
- assert strong.supervisor_calls > 1
544
- assert result["supervisor_decision"] == "nudge"
 
545
 
546
 
547
  def test_final_answer_gets_supervisor_review_and_can_be_returned_for_revision(monkeypatch, tmp_path):
 
428
  assert strong.finalizer_calls == 1
429
 
430
 
431
+ def test_supervisor_finalizes_even_with_placeholder_if_requested(monkeypatch, tmp_path):
432
  class FakeBoundModel:
433
  def __init__(self):
434
  self.calls = 0
435
 
436
  def invoke(self, messages):
437
  self.calls += 1
 
 
438
  return _ai_with_calls([
439
  {
440
  "id": f"call-{self.calls}",
 
456
  prompt = str(messages[0].content)
457
  if "SUPERVISOR FINALIZER" in prompt:
458
  self.finalizer_calls += 1
459
+ return AIMessage(content="Final Answer: finalizer output")
460
  self.supervisor_calls += 1
461
  if self.supervisor_calls == 1:
462
  return AIMessage(content='{"status":"nudge","best_answer":"","guidance":"Use the evidence to make a best guess."}')
463
+ # On second call, it asks to finalize but with a placeholder answer. The code should allow finalization.
464
  return AIMessage(content='{"status":"finalize","best_answer":"Unknown","guidance":"You were already nudged. Provide your final answer based on the best available information."}')
465
 
466
  strong = FakeStrongModel()
 
479
  graph = build_react_agent(cfg)
480
  result = graph.invoke(
481
  {"messages": [HumanMessage(content="Question requiring a concrete answer")], "iterations": 0, "todos": []},
482
+ {"configurable": {"thread_id": "supervisor-finalize-after-nudge-test"}},
483
  )
484
 
485
+ assert result["messages"][-1].content == "Final Answer: finalizer output"
486
+ assert strong.finalizer_calls == 1
487
  assert strong.supervisor_calls == 2
488
 
489
 
490
+ def test_supervisor_forces_finalize_after_max_nudges(monkeypatch, tmp_path):
491
  class FakeBoundModel:
492
  def __init__(self):
493
  self.calls = 0
 
515
  prompt = str(messages[0].content)
516
  if "SUPERVISOR FINALIZER" in prompt:
517
  self.finalizer_calls += 1
518
+ return AIMessage(content="Final Answer: max nudges forced this")
519
  self.supervisor_calls += 1
520
  return AIMessage(content='{"status":"nudge","best_answer":"concrete candidate","guidance":"Check one more constraint before final answer."}')
521
 
522
  strong = FakeStrongModel()
523
  cfg = Config.from_env()
524
+ cfg.recursion_limit = 20
525
  cfg.budget_hard_cap = 99
526
  cfg.budget_warn_at = 99
527
  cfg.compact_summarize = False
528
  monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
529
  monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MIN_TOOL_CALLS", 1, raising=False)
530
+ monkeypatch.setattr("lilith_agent.app._SUPERVISOR_MAX_NUDGES", 5, raising=False)
531
  monkeypatch.setattr("lilith_agent.app.get_extra_strong_model", lambda cfg: strong)
532
  monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: object())
533
  monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
 
536
  graph = build_react_agent(cfg)
537
  result = graph.invoke(
538
  {"messages": [HumanMessage(content="Question requiring more checking")], "iterations": 0, "todos": []},
539
+ {"configurable": {"thread_id": "supervisor-max-nudges-test"}},
540
  )
541
 
542
  assert strong.finalizer_calls == 0
543
+ assert result["messages"][-1].content == "Final Answer: concrete candidate"
544
+ assert strong.supervisor_calls == 5
545
+ assert result["supervisor_decision"] == "finalize"
546
 
547
 
548
  def test_final_answer_gets_supervisor_review_and_can_be_returned_for_revision(monkeypatch, tmp_path):