atakan Claude Fable 5 commited on
Commit
2739ebf
·
1 Parent(s): 4de16e3

fix: Extend provenance guard to 1D arrays; repair trailing-comma JSON

Browse files

Reported bug: "what is routh hurwitz explain with an example" produced a
routh_hurwitz_analysis call with a 300+-element mostly-zero coefficients
array, and the raw tool-call JSON leaked directly into the chat instead
of executing or being cleanly blocked. Traced to two separate, precisely
verified gaps:

1. The provenance guard added earlier only checked 2D nested arrays
(matrices like A, B, Q, K) -- confirmed live that a fabricated 1D
array such as `coefficients: [1,0,0,...]` sailed straight through
uncaught, and 1D arrays (numerator, denominator, coefficients) are
the primary parameter type for roughly half the registered tools.

A strict "must match the conversation verbatim" rule doesn't work for
1D arrays the way it does for matrices: a legitimately *expanded*
factored polynomial, e.g. (s+1)(s+2)(s+3) -> [1,6,11,6], never
appears literally in the user's text, and would be wrongly blocked.
So instead of provenance-tracing, this targets the actual observed
failure mode directly: reject arrays that are absurdly long (>25
elements -- no real hand-solved control problem needs more) or that
repeat the same value 6+ times in a row (a runaway generation loop,
not real data). Verified against the exact failure, three legitimate
cases that must NOT be blocked (expanded polynomial, short array,
long-but-real 20-element array), and the repetition-run path
independent of length -- all five behave correctly.

2. Even with #1 unfixed, this should have failed to *parse* rather than
leak as raw text -- confirmed why it didn't: the runaway generation
got cut off by the token budget right after a comma mid-array, and
close_unbalanced_json only appended the missing closing brackets
without noticing the resulting dangling trailing comma made the JSON
invalid regardless ("[1, 0, 0,]" still fails to parse). Now strips a
trailing comma before balancing brackets.

Verified end-to-end on the exact reported question: routh_hurwitz_analysis
now runs successfully with a small self-chosen illustrative polynomial
(exactly right for an "explain with an example" request) instead of
inventing an oversized array or leaking JSON.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (1) hide show
  1. controlai_agent/orchestrator.py +55 -11
controlai_agent/orchestrator.py CHANGED
@@ -102,6 +102,12 @@ def close_unbalanced_json(raw: str) -> str:
102
  repaired = raw
103
  if in_string:
104
  repaired += '"'
 
 
 
 
 
 
105
  for opener in reversed(stack):
106
  repaired += "}" if opener == "{" else "]"
107
  return repaired
@@ -406,17 +412,51 @@ class ParameterProvenance:
406
  return False
407
 
408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  def _check_parameter_provenance(
410
  tool_args: dict[str, Any], messages: list[dict[str, Any]]
411
- ) -> tuple[str, Any] | None:
412
- """Return (param_name, value) for the first fabricated matrix, else None."""
413
  provenance = ParameterProvenance(messages)
414
  for param, value in tool_args.items():
415
  if param in _PROVENANCE_EXEMPT_PARAMS:
416
  continue
417
- if isinstance(value, list) and value and isinstance(value[0], list):
 
 
418
  if not provenance.verify(value):
419
- return param, value
 
 
 
 
420
  return None
421
 
422
 
@@ -685,17 +725,21 @@ class ControlAIAgent:
685
  except Exception:
686
  fabricated = None # the guard must never take down a legitimate call
687
  if fabricated is not None:
688
- param, value = fabricated
 
 
 
 
689
  return {
690
  "status": "error",
691
  "error_type": "FabricatedParameter",
692
  "error": (
693
- f"REFUSED: the matrix passed as '{param}' = {json.dumps(value)} was not provided by "
694
- f"the user in this conversation and did not come from any prior tool result. Inventing "
695
- f"parameter values is forbidden. Do NOT retry this tool with a different guessed "
696
- f"'{param}'. In your final answer, tell the user that '{param}' is required for this "
697
- f"computation and ask them to provide it, and answer whatever part of their question "
698
- f"does not need it."
699
  ),
700
  }
701
  return self.registry.execute(tool_name, tool_args)
 
102
  repaired = raw
103
  if in_string:
104
  repaired += '"'
105
+ # A generation cut off by the token budget mid-array/object usually stops
106
+ # right after a comma (about to write the next element) -- a dangling
107
+ # trailing comma is invalid JSON even once the brackets below are
108
+ # balanced ("[1, 0, 0,]" still fails to parse), so drop it first.
109
+ if not in_string:
110
+ repaired = re.sub(r",\s*$", "", repaired)
111
  for opener in reversed(stack):
112
  repaired += "}" if opener == "{" else "]"
113
  return repaired
 
412
  return False
413
 
414
 
415
+ # Real control-engineering coefficient/numerator/denominator arrays are
416
+ # essentially always short -- a 10th-order polynomial (already an extreme
417
+ # hand-solved case) has 11 coefficients. A flat numeric array cannot be
418
+ # provenance-traced the way a matrix can (a legitimately *expanded* factored
419
+ # polynomial, e.g. (s+1)(s+2)(s+3) -> [1,6,11,6], never appears verbatim in
420
+ # the user's text, so a strict "must match the conversation" rule would
421
+ # wrongly block correct derivations). Instead this catches the actual
422
+ # observed failure mode directly: a runaway repetition loop, where a small
423
+ # model gets stuck emitting the same value and the token budget cuts it off
424
+ # mid-array -- e.g. 300+ elements of mostly zeros for routh_hurwitz_analysis
425
+ # on a question that never gave it a polynomial at all.
426
+ _MAX_SANE_1D_ARRAY_LEN = 25
427
+ _MAX_IDENTICAL_RUN = 6
428
+
429
+
430
+ def _degenerate_array_reason(values: list) -> str | None:
431
+ if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values):
432
+ return None
433
+ if len(values) > _MAX_SANE_1D_ARRAY_LEN:
434
+ return f"has {len(values)} elements, far beyond any real control-engineering array of this kind"
435
+ run = 1
436
+ for i in range(1, len(values)):
437
+ run = run + 1 if values[i] == values[i - 1] else 1
438
+ if run >= _MAX_IDENTICAL_RUN:
439
+ return f"repeats the value {values[i]!r} {run}+ times in a row -- a runaway generation loop, not real data"
440
+ return None
441
+
442
+
443
  def _check_parameter_provenance(
444
  tool_args: dict[str, Any], messages: list[dict[str, Any]]
445
+ ) -> tuple[str, Any, str] | None:
446
+ """Return (param_name, value, reason) for the first bad matrix/array, else None."""
447
  provenance = ParameterProvenance(messages)
448
  for param, value in tool_args.items():
449
  if param in _PROVENANCE_EXEMPT_PARAMS:
450
  continue
451
+ if not (isinstance(value, list) and value):
452
+ continue
453
+ if isinstance(value[0], list):
454
  if not provenance.verify(value):
455
+ return param, value, "was not provided by the user in this conversation and did not come from any prior tool result"
456
+ else:
457
+ reason = _degenerate_array_reason(value)
458
+ if reason:
459
+ return param, value, reason
460
  return None
461
 
462
 
 
725
  except Exception:
726
  fabricated = None # the guard must never take down a legitimate call
727
  if fabricated is not None:
728
+ param, value, reason = fabricated
729
+ # Never echo a runaway array (possibly hundreds of elements) back
730
+ # into the context -- it wastes the token budget and risks
731
+ # priming the exact same repetition pathology again.
732
+ shown = value if len(json.dumps(value)) < 200 else f"[{len(value)}-element array, truncated]"
733
  return {
734
  "status": "error",
735
  "error_type": "FabricatedParameter",
736
  "error": (
737
+ f"REFUSED: the value passed as '{param}' ({shown}) {reason}. Inventing or "
738
+ f"malformed parameter values is forbidden. Do NOT retry this tool with another "
739
+ f"guessed or partially-repeated '{param}' -- if you don't actually have a concrete "
740
+ f"value for it, this tool cannot be used for this question. Answer from your own "
741
+ f"knowledge or the retrieved reference passages instead, or tell the user what's "
742
+ f"missing."
743
  ),
744
  }
745
  return self.registry.execute(tool_name, tool_args)