atakan commited on
Commit
c008bd9
·
1 Parent(s): 53fb4c4

fix: Repetition-loop generation, redundant tool spam, and KaTeX placeholder leak

Browse files

Three 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.

Files changed (2) hide show
  1. controlai_agent/orchestrator.py +90 -1
  2. web/app.js +19 -4
controlai_agent/orchestrator.py CHANGED
@@ -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
 
web/app.js CHANGED
@@ -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