fix(agent): detect truncated scenario JSON for clear retry feedback
Observed in a live checkpoint: the pending scenario_validate tool call carried a scenario_json that the LLM stream cut mid-document (ended inside the unclosed outer object, len 3837). _parse_json_value failed with a generic 'Could not extract JSON value' that neither the operator nor the LLM could act on. Add _looks_truncated (unbalanced structure / unterminated string at end) and report 'truncated/incomplete JSON' with input length, so the model regenerates the full document.
This commit is contained in:
@@ -86,6 +86,36 @@ def _guard_tool_permission(tool_name: str) -> None:
|
||||
# #endregion AgentChat.ToolsScenarioGraph.GuardPermission
|
||||
|
||||
|
||||
# #region AgentChat.ToolsScenarioGraph.LooksTruncated [C:2] [TYPE Function] [SEMANTICS scenario,helpers,json,truncation]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Detect JSON text that ends inside an unclosed structure — LLM output cut mid-document.
|
||||
# @POST Returns True when the input ends with unbalanced braces/brackets or inside a string.
|
||||
# @RATIONALE Distinguishes "truncated JSON" (LLM stream cut off — observed: scenario_json in a
|
||||
# checkpoint ended at '...null}]' with the outer object unclosed) from "no JSON at all", so the
|
||||
# error tells the LLM/operator to regenerate the full document instead of looking for a typo.
|
||||
def _looks_truncated(text: str) -> bool:
|
||||
depth = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
for c in text or "":
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif c == "\\":
|
||||
esc = True
|
||||
elif c == '"':
|
||||
in_str = False
|
||||
continue
|
||||
if c == '"':
|
||||
in_str = True
|
||||
elif c in "{[":
|
||||
depth += 1
|
||||
elif c in "}]":
|
||||
depth -= 1
|
||||
return depth > 0 or in_str
|
||||
# #endregion AgentChat.ToolsScenarioGraph.LooksTruncated
|
||||
|
||||
|
||||
# #region AgentChat.ToolsScenarioGraph.ParseJsonValue [C:2] [TYPE Function] [SEMANTICS scenario,helpers,json,parse,robust]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Parse the first complete JSON value (object OR array) from an LLM string, tolerating trailing content.
|
||||
@@ -139,7 +169,15 @@ def _parse_json_value(text: str) -> Any:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return json.loads(s[start : i + 1])
|
||||
raise ValueError(f"Could not extract JSON value from tool argument: {text[:200]!r}")
|
||||
reason = (
|
||||
"truncated/incomplete JSON — input ends inside an unclosed structure"
|
||||
if _looks_truncated(s)
|
||||
else "no valid JSON object or array found"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Could not extract JSON value from tool argument: {text[:200]!r} "
|
||||
f"({reason}, input length {len(s)})"
|
||||
)
|
||||
# #endregion AgentChat.ToolsScenarioGraph.ParseJsonValue
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,33 @@ def test_invalid_raises_value_error():
|
||||
_parse("this is not json at all")
|
||||
|
||||
|
||||
def test_truncated_json_error_mentions_truncation():
|
||||
"""A scenario_json cut mid-document (observed: LLM stream ended inside the
|
||||
object) must be reported as truncated so the LLM regenerates the full
|
||||
document instead of hunting for a typo."""
|
||||
truncated = '{"schema_version": 1, "steps": [{"id": "s1", "title": "B01", "vlm_analysis": null'
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_parse(truncated)
|
||||
msg = str(exc.value)
|
||||
assert "truncated" in msg
|
||||
assert "input length" in msg
|
||||
|
||||
|
||||
def test_truncated_json_inside_string_detected():
|
||||
"""Input ending inside an unterminated string literal is also truncation."""
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_parse('{"goal": "verify')
|
||||
assert "truncated" in str(exc.value)
|
||||
|
||||
|
||||
def test_non_json_error_does_not_claim_truncation():
|
||||
"""Plain non-JSON text must be reported as 'no valid JSON', not truncation."""
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_parse("this is not json at all")
|
||||
assert "truncated" not in str(exc.value)
|
||||
assert "no valid JSON" in str(exc.value)
|
||||
|
||||
|
||||
def test_empty_array_value():
|
||||
"""scenario_resolve passes `changes` as a JSON array — value parser must accept it."""
|
||||
assert _parse_value("[]") == []
|
||||
|
||||
Reference in New Issue
Block a user