atakan commited on
Commit
4a647b7
·
1 Parent(s): c008bd9

fix: Stop final synthesis from hallucinating past a fabrication refusal

Browse files

The provenance guard correctly blocked a tool call that invented a missing
B/Q/R, but the model's next turn -- whether the forced final-synthesis
message or the "stopped calling tools" early return -- had no idea the
refusal happened and would confidently print a fully invented numeric
answer anyway (reproduced directly: "design an LQR controller for A=..."
with no B/Q/R given still returned a specific K value after the refusal).

Now any FabricatedParameter refusal in a turn is tracked and both synthesis
paths are routed through an explicit instruction not to invent a
substitute value -- the early-return path no longer trusts unchecked
pre_text once a refusal has happened this turn, and falls through to the
same forced turn instead.

Files changed (1) hide show
  1. controlai_agent/orchestrator.py +56 -4
controlai_agent/orchestrator.py CHANGED
@@ -518,6 +518,32 @@ def _needs_tools(user_prompt: str) -> bool:
518
  return any(kw in lower for kw in _COMPUTATION_KEYWORDS)
519
 
520
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  class ControlAIAgent:
522
  """Universal Control Engineering Agent supporting GGUF, Ollama C++, Apple MLX, and PyTorch."""
523
 
@@ -851,6 +877,7 @@ class ControlAIAgent:
851
  plots: list[str] = []
852
  called_signatures: set[str] = set()
853
  tool_call_counts: dict[str, int] = {}
 
854
 
855
  for step in range(1, self.max_tool_steps + 1):
856
  rendered_prompt = self.hf_tokenizer.apply_chat_template(
@@ -864,7 +891,7 @@ class ControlAIAgent:
864
 
865
  tool_calls, pre_text = _extract_tool_calls(model_output)
866
 
867
- if not tool_calls:
868
  # The model chose to stop calling tools but produced no usable
869
  # text either (typically right after a tool error it has no
870
  # good way to recover from in-context). Retry once with no
@@ -882,6 +909,17 @@ class ControlAIAgent:
882
  plots=plots,
883
  )
884
 
 
 
 
 
 
 
 
 
 
 
 
885
  # Drop repeats. An exact-signature check alone is not enough: the
886
  # model will re-search with a lightly reworded query ("... MPC",
887
  # "... MPC algorithm", "... MPC definition"), exhausting the step
@@ -910,6 +948,8 @@ class ControlAIAgent:
910
 
911
  tool_result = self._execute_with_provenance(tool_name, tool_args, messages)
912
  traces.append(ToolExecutionTrace(tool_name=tool_name, arguments=tool_args, result=tool_result))
 
 
913
 
914
  if "plot_path" in tool_result:
915
  p_path = Path(tool_result["plot_path"])
@@ -932,7 +972,7 @@ class ControlAIAgent:
932
  "above don't actually address it, answer the question from your own knowledge instead of "
933
  "describing the tool results. Do not call any more tools and do not output JSON or "
934
  "tool-call tags -- write the final answer now."
935
- ),
936
  })
937
  forced_prompt = self.hf_tokenizer.apply_chat_template(
938
  messages,
@@ -1018,6 +1058,7 @@ class ControlAIAgent:
1018
  thoughts: list[str] = []
1019
  called_signatures: set[str] = set()
1020
  tool_call_counts: dict[str, int] = {}
 
1021
 
1022
  # Dynamic thought generation - only show thoughts when tools or derivations occur
1023
  for step in range(1, self.max_tool_steps + 1):
@@ -1032,7 +1073,7 @@ class ControlAIAgent:
1032
 
1033
  tool_calls, pre_text = _extract_tool_calls(model_output)
1034
 
1035
- if not tool_calls:
1036
  # Direct final answer without tools -> stream tokens directly
1037
  clean_output = pre_text or self._direct_answer(user_prompt, effective_sys, history)
1038
  if not clean_output:
@@ -1051,6 +1092,15 @@ class ControlAIAgent:
1051
  }
1052
  return
1053
 
 
 
 
 
 
 
 
 
 
1054
  if pre_text:
1055
  thoughts.append(pre_text)
1056
  yield {"type": "thought", "content": pre_text}
@@ -1094,6 +1144,8 @@ class ControlAIAgent:
1094
  "residual": tool_result.get("residual"),
1095
  }
1096
  traces.append(trace_item)
 
 
1097
 
1098
  if "plot_path" in tool_result:
1099
  p_path = Path(tool_result["plot_path"])
@@ -1127,7 +1179,7 @@ class ControlAIAgent:
1127
  "above don't actually address it, answer the question from your own knowledge instead of "
1128
  "describing the tool results. Do not call any more tools and do not output JSON or "
1129
  "tool-call tags -- write the final answer now."
1130
- ),
1131
  })
1132
 
1133
  forced_prompt = self.hf_tokenizer.apply_chat_template(
 
518
  return any(kw in lower for kw in _COMPUTATION_KEYWORDS)
519
 
520
 
521
+ def _fabrication_refusal_note(refusals: list[str]) -> str:
522
+ """Extra synthesis instruction appended when a tool call was refused for
523
+ inventing a parameter.
524
+
525
+ Without this, the model's forced final-answer turn happily hallucinates a
526
+ plausible-looking numeric replacement anyway: observed directly with
527
+ sft_v2 on "design an LQR controller for A = [[0, 1], [-2, -3]]" (no B, Q,
528
+ R given) -- the tool call was correctly REFUSED by the provenance guard,
529
+ but the final prose still confidently printed a fully invented gain
530
+ $K = [1.83, 1.83]$, silently routing around its own refusal. The system
531
+ prompt already forbids this (rule 6), but that alone doesn't hold on a 4B
532
+ model any more than the provenance rules did in prose form -- so the
533
+ refusal is restated directly in the synthesis turn itself, where it can't
534
+ be missed.
535
+ """
536
+ if not refusals:
537
+ return ""
538
+ return (
539
+ "\n\nIMPORTANT: at least one tool call above was REFUSED for inventing a parameter you were never "
540
+ "given (see the REFUSED error message(s) in the tool results above for exactly which one and why). "
541
+ "This means you do NOT have enough information to compute a numeric answer for that part of the "
542
+ "request. Do NOT invent a substitute number, gain, matrix, or result to answer anyway -- state "
543
+ "plainly what is missing and ask the user for it instead of guessing."
544
+ )
545
+
546
+
547
  class ControlAIAgent:
548
  """Universal Control Engineering Agent supporting GGUF, Ollama C++, Apple MLX, and PyTorch."""
549
 
 
877
  plots: list[str] = []
878
  called_signatures: set[str] = set()
879
  tool_call_counts: dict[str, int] = {}
880
+ fabrication_refusals: list[str] = []
881
 
882
  for step in range(1, self.max_tool_steps + 1):
883
  rendered_prompt = self.hf_tokenizer.apply_chat_template(
 
891
 
892
  tool_calls, pre_text = _extract_tool_calls(model_output)
893
 
894
+ if not tool_calls and not fabrication_refusals:
895
  # The model chose to stop calling tools but produced no usable
896
  # text either (typically right after a tool error it has no
897
  # good way to recover from in-context). Retry once with no
 
909
  plots=plots,
910
  )
911
 
912
+ if not tool_calls:
913
+ # A fabrication refusal happened earlier this turn -- pre_text
914
+ # here is exactly the kind of confident, ungrounded prose that
915
+ # refusal was meant to prevent (observed directly: a blocked
916
+ # LQR call still produced a fully invented gain in this same
917
+ # spot). Don't trust it at face value; fall through to the
918
+ # shared forced-synthesis turn below instead of returning,
919
+ # since that turn explicitly instructs against inventing a
920
+ # substitute value.
921
+ break
922
+
923
  # Drop repeats. An exact-signature check alone is not enough: the
924
  # model will re-search with a lightly reworded query ("... MPC",
925
  # "... MPC algorithm", "... MPC definition"), exhausting the step
 
948
 
949
  tool_result = self._execute_with_provenance(tool_name, tool_args, messages)
950
  traces.append(ToolExecutionTrace(tool_name=tool_name, arguments=tool_args, result=tool_result))
951
+ if tool_result.get("error_type") == "FabricatedParameter":
952
+ fabrication_refusals.append(tool_result.get("error", ""))
953
 
954
  if "plot_path" in tool_result:
955
  p_path = Path(tool_result["plot_path"])
 
972
  "above don't actually address it, answer the question from your own knowledge instead of "
973
  "describing the tool results. Do not call any more tools and do not output JSON or "
974
  "tool-call tags -- write the final answer now."
975
+ ) + _fabrication_refusal_note(fabrication_refusals),
976
  })
977
  forced_prompt = self.hf_tokenizer.apply_chat_template(
978
  messages,
 
1058
  thoughts: list[str] = []
1059
  called_signatures: set[str] = set()
1060
  tool_call_counts: dict[str, int] = {}
1061
+ fabrication_refusals: list[str] = []
1062
 
1063
  # Dynamic thought generation - only show thoughts when tools or derivations occur
1064
  for step in range(1, self.max_tool_steps + 1):
 
1073
 
1074
  tool_calls, pre_text = _extract_tool_calls(model_output)
1075
 
1076
+ if not tool_calls and not fabrication_refusals:
1077
  # Direct final answer without tools -> stream tokens directly
1078
  clean_output = pre_text or self._direct_answer(user_prompt, effective_sys, history)
1079
  if not clean_output:
 
1092
  }
1093
  return
1094
 
1095
+ if not tool_calls:
1096
+ # A fabrication refusal happened earlier this turn -- pre_text
1097
+ # here is exactly the kind of confident, ungrounded prose that
1098
+ # refusal was meant to prevent. Don't trust it at face value;
1099
+ # fall through to the shared forced-synthesis turn below,
1100
+ # which explicitly instructs against inventing a substitute
1101
+ # value, instead of streaming it straight to the user.
1102
+ break
1103
+
1104
  if pre_text:
1105
  thoughts.append(pre_text)
1106
  yield {"type": "thought", "content": pre_text}
 
1144
  "residual": tool_result.get("residual"),
1145
  }
1146
  traces.append(trace_item)
1147
+ if tool_result.get("error_type") == "FabricatedParameter":
1148
+ fabrication_refusals.append(tool_result.get("error", ""))
1149
 
1150
  if "plot_path" in tool_result:
1151
  p_path = Path(tool_result["plot_path"])
 
1179
  "above don't actually address it, answer the question from your own knowledge instead of "
1180
  "describing the tool results. Do not call any more tools and do not output JSON or "
1181
  "tool-call tags -- write the final answer now."
1182
+ ) + _fabrication_refusal_note(fabrication_refusals),
1183
  })
1184
 
1185
  forced_prompt = self.hf_tokenizer.apply_chat_template(