Spaces:
Running on Zero
fix: Repetition-loop generation, redundant tool spam, and KaTeX placeholder leak
Browse filesThree compounding causes of "slow and wrong" answers, all confirmed by
reproducing them end-to-end:
- llama_cpp's repeat_penalty defaults to 1.0 (fully off) unless set
explicitly, and combined with low-temperature decoding on the quantized
4B model this is what produced runaway token-repetition loops (e.g. 300+
repeated zeros in a routh_hurwitz_analysis coefficient array). Added an
explicit repetition penalty across all four inference backends
(llama_cpp, MLX, transformers, Ollama).
- Conceptual/definitional questions that already have strong grounded
reference passages injected into the system prompt were still driving
the model through a full multi-step tool loop (a redundant re-search
plus unrelated numeric tools), burning 5 sequential generations where
one would do. Added a code-enforced RAG fast path -- prompting alone
doesn't reliably stop this on a small model any more than it does for
parameter provenance, so it's now the same "enforce in code" pattern.
- A \begin{bmatrix}...\end{bmatrix} block wrapped in outer $$...$$ gets
extracted twice by the KaTeX renderer: the inner placeholder ends up
embedded as literal text inside the outer formula, so KaTeX renders the
placeholder token itself instead of the matrix (visible as literal
"KATEXBLOCK0KATEX" text in the chat). Nested placeholders are now
resolved back to their original formula before being handed to KaTeX.
- controlai_agent/orchestrator.py +90 -1
- web/app.js +19 -4
|
@@ -13,6 +13,7 @@ import sys
|
|
| 13 |
|
| 14 |
try:
|
| 15 |
from mlx_lm import generate as mlx_generate, load as mlx_load
|
|
|
|
| 16 |
HAS_MLX = True
|
| 17 |
except ImportError:
|
| 18 |
HAS_MLX = False
|
|
@@ -228,6 +229,14 @@ def _extract_tool_calls(text: str) -> tuple[list[dict[str, Any]], str]:
|
|
| 228 |
return calls, cleaned
|
| 229 |
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 232 |
|
| 233 |
# How many times a single tool may be invoked within one user turn. Two allows
|
|
@@ -470,6 +479,45 @@ def _check_parameter_provenance(
|
|
| 470 |
return None
|
| 471 |
|
| 472 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
class ControlAIAgent:
|
| 474 |
"""Universal Control Engineering Agent supporting GGUF, Ollama C++, Apple MLX, and PyTorch."""
|
| 475 |
|
|
@@ -597,15 +645,18 @@ class ControlAIAgent:
|
|
| 597 |
res = ollama.generate(
|
| 598 |
model=self.ollama_model,
|
| 599 |
prompt=prompt,
|
| 600 |
-
options={"temperature": 0.2, "num_predict": max_tokens},
|
| 601 |
)
|
| 602 |
return res.get("response", "").strip()
|
| 603 |
elif self.is_gguf:
|
|
|
|
|
|
|
| 604 |
output = self.llama_model(
|
| 605 |
prompt,
|
| 606 |
max_tokens=max_tokens,
|
| 607 |
stop=["<|im_end|>", "<|endoftext|>"],
|
| 608 |
temperature=0.2,
|
|
|
|
| 609 |
)
|
| 610 |
return output["choices"][0]["text"].strip()
|
| 611 |
elif self.is_mlx:
|
|
@@ -614,6 +665,7 @@ class ControlAIAgent:
|
|
| 614 |
self.mlx_tokenizer,
|
| 615 |
prompt=prompt,
|
| 616 |
max_tokens=max_tokens,
|
|
|
|
| 617 |
verbose=False,
|
| 618 |
).strip()
|
| 619 |
else:
|
|
@@ -636,6 +688,7 @@ class ControlAIAgent:
|
|
| 636 |
temperature=None,
|
| 637 |
top_p=None,
|
| 638 |
top_k=None,
|
|
|
|
| 639 |
eos_token_id=eos_ids,
|
| 640 |
pad_token_id=self.hf_tokenizer.pad_token_id or self.hf_tokenizer.eos_token_id,
|
| 641 |
)
|
|
@@ -765,6 +818,22 @@ class ControlAIAgent:
|
|
| 765 |
"""Execute a complete agent interaction loop synchronously."""
|
| 766 |
messages: list[dict[str, Any]] = []
|
| 767 |
effective_sys = self._get_grounded_instruction(user_prompt, system_instruction)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
if effective_sys:
|
| 769 |
messages.append({"role": "system", "content": effective_sys})
|
| 770 |
|
|
@@ -907,6 +976,26 @@ class ControlAIAgent:
|
|
| 907 |
"""Stream token-by-token generation and tool execution events with zero JSON leakage."""
|
| 908 |
messages: list[dict[str, Any]] = []
|
| 909 |
effective_sys = self._get_grounded_instruction(user_prompt, system_instruction)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
if effective_sys:
|
| 911 |
messages.append({"role": "system", "content": effective_sys})
|
| 912 |
|
|
|
|
| 13 |
|
| 14 |
try:
|
| 15 |
from mlx_lm import generate as mlx_generate, load as mlx_load
|
| 16 |
+
from mlx_lm.sample_utils import make_logits_processors
|
| 17 |
HAS_MLX = True
|
| 18 |
except ImportError:
|
| 19 |
HAS_MLX = False
|
|
|
|
| 229 |
return calls, cleaned
|
| 230 |
|
| 231 |
|
| 232 |
+
# Applied across every inference backend below. Without it, a small quantized
|
| 233 |
+
# model under low-temperature decoding has no defense against falling into a
|
| 234 |
+
# token-repetition loop once it starts (observed in production as
|
| 235 |
+
# routh_hurwitz_analysis coefficient arrays of 300+ repeated zeros): it burns
|
| 236 |
+
# the entire max_tokens budget on garbage, which is both the direct cause of
|
| 237 |
+
# the degenerate-array tool-call failures and a major source of latency.
|
| 238 |
+
REPETITION_PENALTY = 1.15
|
| 239 |
+
|
| 240 |
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 241 |
|
| 242 |
# How many times a single tool may be invoked within one user turn. Two allows
|
|
|
|
| 479 |
return None
|
| 480 |
|
| 481 |
|
| 482 |
+
# ---------------------------------------------------------------------------
|
| 483 |
+
# RAG fast path
|
| 484 |
+
#
|
| 485 |
+
# The system prompt already tells the model "for definitional questions,
|
| 486 |
+
# answer from the retrieved reference passages, do not run a numeric solver"
|
| 487 |
+
# -- but that instruction demonstrably does not hold on a 4B model either: in
|
| 488 |
+
# production, "what is Routh-Hurwitz, explain with an example" (fully
|
| 489 |
+
# answerable from the Nise/Ogata passages already injected into the system
|
| 490 |
+
# prompt by _get_grounded_instruction) still drove the model through 4 tool
|
| 491 |
+
# calls (a redundant re-search plus three unrelated numeric tools) and 5
|
| 492 |
+
# sequential full-length generations before it produced an answer. Since
|
| 493 |
+
# prompting alone can't be trusted here any more than it can for parameter
|
| 494 |
+
# provenance above, this is enforced the same way: in code.
|
| 495 |
+
#
|
| 496 |
+
# A real computational request in this domain essentially always names a
|
| 497 |
+
# concrete number (a coefficient, a gain, a frequency) or an explicit
|
| 498 |
+
# computational verb ("plot", "design", "simulate"); a pure "what is X" /
|
| 499 |
+
# "explain X" / "how does X work" question has neither. This heuristic only
|
| 500 |
+
# ever widens back to the full tool loop on a false negative (a computational
|
| 501 |
+
# question with no digits and none of these verbs) -- it never narrows
|
| 502 |
+
# correctness, since the loop it skips still runs whenever this returns True.
|
| 503 |
+
_COMPUTATION_KEYWORDS = (
|
| 504 |
+
"plot", "simulate", "simulation", "compute", "calculate", "design",
|
| 505 |
+
"solve", "gain", "matrix", "matrices", "pole", "place", "locus", "bode",
|
| 506 |
+
"nyquist", "margin", "response", "transfer function", "eigen",
|
| 507 |
+
"controllab", "observab", "lyapunov", "kalman", "mpc", "invert",
|
| 508 |
+
"determinant", "transpose", "multiply", "rank", "code", "script",
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def _needs_tools(user_prompt: str) -> bool:
|
| 513 |
+
"""True if the question plausibly needs a numeric tool rather than being
|
| 514 |
+
answerable straight from grounded reference text."""
|
| 515 |
+
if any(ch.isdigit() for ch in user_prompt):
|
| 516 |
+
return True
|
| 517 |
+
lower = user_prompt.lower()
|
| 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 |
|
|
|
|
| 645 |
res = ollama.generate(
|
| 646 |
model=self.ollama_model,
|
| 647 |
prompt=prompt,
|
| 648 |
+
options={"temperature": 0.2, "num_predict": max_tokens, "repeat_penalty": REPETITION_PENALTY},
|
| 649 |
)
|
| 650 |
return res.get("response", "").strip()
|
| 651 |
elif self.is_gguf:
|
| 652 |
+
# llama_cpp defaults repeat_penalty to 1.0 (fully off) when unset --
|
| 653 |
+
# it does NOT inherit any sane default, so this must be passed explicitly.
|
| 654 |
output = self.llama_model(
|
| 655 |
prompt,
|
| 656 |
max_tokens=max_tokens,
|
| 657 |
stop=["<|im_end|>", "<|endoftext|>"],
|
| 658 |
temperature=0.2,
|
| 659 |
+
repeat_penalty=REPETITION_PENALTY,
|
| 660 |
)
|
| 661 |
return output["choices"][0]["text"].strip()
|
| 662 |
elif self.is_mlx:
|
|
|
|
| 665 |
self.mlx_tokenizer,
|
| 666 |
prompt=prompt,
|
| 667 |
max_tokens=max_tokens,
|
| 668 |
+
logits_processors=make_logits_processors(repetition_penalty=REPETITION_PENALTY),
|
| 669 |
verbose=False,
|
| 670 |
).strip()
|
| 671 |
else:
|
|
|
|
| 688 |
temperature=None,
|
| 689 |
top_p=None,
|
| 690 |
top_k=None,
|
| 691 |
+
repetition_penalty=REPETITION_PENALTY,
|
| 692 |
eos_token_id=eos_ids,
|
| 693 |
pad_token_id=self.hf_tokenizer.pad_token_id or self.hf_tokenizer.eos_token_id,
|
| 694 |
)
|
|
|
|
| 818 |
"""Execute a complete agent interaction loop synchronously."""
|
| 819 |
messages: list[dict[str, Any]] = []
|
| 820 |
effective_sys = self._get_grounded_instruction(user_prompt, system_instruction)
|
| 821 |
+
|
| 822 |
+
# RAG fast path: a conceptual/definitional question that already has
|
| 823 |
+
# strong grounded passages needs one generation, not a multi-step tool
|
| 824 |
+
# loop. See the _needs_tools docstring for why this is safe.
|
| 825 |
+
if effective_sys != system_instruction and not _needs_tools(user_prompt):
|
| 826 |
+
fast_answer = self._direct_answer(user_prompt, effective_sys, history)
|
| 827 |
+
if fast_answer:
|
| 828 |
+
return AgentResult(
|
| 829 |
+
final_response=fast_answer,
|
| 830 |
+
tool_traces=[],
|
| 831 |
+
total_steps=1,
|
| 832 |
+
raw_messages=[{"role": "system", "content": effective_sys}, {"role": "user", "content": user_prompt}],
|
| 833 |
+
is_grounded=True,
|
| 834 |
+
plots=[],
|
| 835 |
+
)
|
| 836 |
+
|
| 837 |
if effective_sys:
|
| 838 |
messages.append({"role": "system", "content": effective_sys})
|
| 839 |
|
|
|
|
| 976 |
"""Stream token-by-token generation and tool execution events with zero JSON leakage."""
|
| 977 |
messages: list[dict[str, Any]] = []
|
| 978 |
effective_sys = self._get_grounded_instruction(user_prompt, system_instruction)
|
| 979 |
+
|
| 980 |
+
# RAG fast path: a conceptual/definitional question that already has
|
| 981 |
+
# strong grounded passages needs one generation, not a multi-step tool
|
| 982 |
+
# loop. See the _needs_tools docstring for why this is safe.
|
| 983 |
+
if effective_sys != system_instruction and not _needs_tools(user_prompt):
|
| 984 |
+
fast_answer = self._direct_answer(user_prompt, effective_sys, history)
|
| 985 |
+
if fast_answer:
|
| 986 |
+
words = re.split(r"(\s+)", fast_answer)
|
| 987 |
+
for w in words:
|
| 988 |
+
if w:
|
| 989 |
+
yield {"type": "token", "content": w}
|
| 990 |
+
yield {
|
| 991 |
+
"type": "done",
|
| 992 |
+
"response": fast_answer,
|
| 993 |
+
"traces": [],
|
| 994 |
+
"plots": [],
|
| 995 |
+
"thoughts": [],
|
| 996 |
+
}
|
| 997 |
+
return
|
| 998 |
+
|
| 999 |
if effective_sys:
|
| 1000 |
messages.append({"role": "system", "content": effective_sys})
|
| 1001 |
|
|
@@ -145,6 +145,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 145 |
const mathPlaceholders = [];
|
| 146 |
let text = rawText;
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
// Clean up any raw LaTeX document structure artifacts
|
| 149 |
text = text.replace(/\\section\*?\{([\s\S]*?)\}/g, '### $1\n\n');
|
| 150 |
text = text.replace(/\\subsection\*?\{([\s\S]*?)\}/g, '#### $1\n\n');
|
|
@@ -176,28 +191,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 176 |
// 1. Extract Display Math: $$ ... $$
|
| 177 |
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (match, formula) => {
|
| 178 |
const ph = `KATEXBLOCK${mathPlaceholders.length}KATEX`;
|
| 179 |
-
mathPlaceholders.push({ type: 'block', formula: formula.trim() });
|
| 180 |
return `\n\n${ph}\n\n`;
|
| 181 |
});
|
| 182 |
|
| 183 |
// 2. Extract Display Math: \[ ... \]
|
| 184 |
text = text.replace(/\\\[([\s\S]*?)\\\]/g, (match, formula) => {
|
| 185 |
const ph = `KATEXBLOCK${mathPlaceholders.length}KATEX`;
|
| 186 |
-
mathPlaceholders.push({ type: 'block', formula: formula.trim() });
|
| 187 |
return `\n\n${ph}\n\n`;
|
| 188 |
});
|
| 189 |
|
| 190 |
// 3. Extract Inline Math: $ ... $ (excluding empty or multi-line)
|
| 191 |
text = text.replace(/\$([^\$\n]+?)\$/g, (match, formula) => {
|
| 192 |
const ph = `KATEXINLINE${mathPlaceholders.length}KATEX`;
|
| 193 |
-
mathPlaceholders.push({ type: 'inline', formula: formula.trim() });
|
| 194 |
return ph;
|
| 195 |
});
|
| 196 |
|
| 197 |
// 4. Extract Inline Math: \( ... \)
|
| 198 |
text = text.replace(/\\\(([\s\S]*?)\\\)/g, (match, formula) => {
|
| 199 |
const ph = `KATEXINLINE${mathPlaceholders.length}KATEX`;
|
| 200 |
-
mathPlaceholders.push({ type: 'inline', formula: formula.trim() });
|
| 201 |
return ph;
|
| 202 |
});
|
| 203 |
|
|
|
|
| 145 |
const mathPlaceholders = [];
|
| 146 |
let text = rawText;
|
| 147 |
|
| 148 |
+
// A later, wider extraction (e.g. step 1's $$...$$) can swallow a
|
| 149 |
+
// placeholder already emitted by an earlier step (e.g. step 0's bare
|
| 150 |
+
// \begin{bmatrix}...\end{bmatrix}), since $$ \begin{bmatrix}...\end{bmatrix} $$
|
| 151 |
+
// is valid LaTeX and gets caught whole. Without this, the captured
|
| 152 |
+
// "formula" is literally the placeholder token text (e.g. "R =
|
| 153 |
+
// KATEXBLOCK0KATEX"), which is not valid LaTeX -- KaTeX then renders
|
| 154 |
+
// that placeholder string itself instead of the matrix it stood for.
|
| 155 |
+
// Resolve any nested placeholder back to its original formula before
|
| 156 |
+
// storing a new one.
|
| 157 |
+
const resolveNestedPlaceholders = (str) =>
|
| 158 |
+
str.replace(/KATEX(?:BLOCK|INLINE)(\d+)KATEX/g, (m, idx) => {
|
| 159 |
+
const item = mathPlaceholders[Number(idx)];
|
| 160 |
+
return item ? item.formula : m;
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
// Clean up any raw LaTeX document structure artifacts
|
| 164 |
text = text.replace(/\\section\*?\{([\s\S]*?)\}/g, '### $1\n\n');
|
| 165 |
text = text.replace(/\\subsection\*?\{([\s\S]*?)\}/g, '#### $1\n\n');
|
|
|
|
| 191 |
// 1. Extract Display Math: $$ ... $$
|
| 192 |
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (match, formula) => {
|
| 193 |
const ph = `KATEXBLOCK${mathPlaceholders.length}KATEX`;
|
| 194 |
+
mathPlaceholders.push({ type: 'block', formula: resolveNestedPlaceholders(formula.trim()) });
|
| 195 |
return `\n\n${ph}\n\n`;
|
| 196 |
});
|
| 197 |
|
| 198 |
// 2. Extract Display Math: \[ ... \]
|
| 199 |
text = text.replace(/\\\[([\s\S]*?)\\\]/g, (match, formula) => {
|
| 200 |
const ph = `KATEXBLOCK${mathPlaceholders.length}KATEX`;
|
| 201 |
+
mathPlaceholders.push({ type: 'block', formula: resolveNestedPlaceholders(formula.trim()) });
|
| 202 |
return `\n\n${ph}\n\n`;
|
| 203 |
});
|
| 204 |
|
| 205 |
// 3. Extract Inline Math: $ ... $ (excluding empty or multi-line)
|
| 206 |
text = text.replace(/\$([^\$\n]+?)\$/g, (match, formula) => {
|
| 207 |
const ph = `KATEXINLINE${mathPlaceholders.length}KATEX`;
|
| 208 |
+
mathPlaceholders.push({ type: 'inline', formula: resolveNestedPlaceholders(formula.trim()) });
|
| 209 |
return ph;
|
| 210 |
});
|
| 211 |
|
| 212 |
// 4. Extract Inline Math: \( ... \)
|
| 213 |
text = text.replace(/\\\(([\s\S]*?)\\\)/g, (match, formula) => {
|
| 214 |
const ph = `KATEXINLINE${mathPlaceholders.length}KATEX`;
|
| 215 |
+
mathPlaceholders.push({ type: 'inline', formula: resolveNestedPlaceholders(formula.trim()) });
|
| 216 |
return ph;
|
| 217 |
});
|
| 218 |
|