Spaces:
Running on Zero
Running on Zero
atakan commited on
Commit ·
4a647b7
1
Parent(s): c008bd9
fix: Stop final synthesis from hallucinating past a fabrication refusal
Browse filesThe 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.
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(
|