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:
2026-08-19 19:52:16 +03:00
parent 9b0350b3b0
commit 614f675f64
2 changed files with 66 additions and 1 deletions

View File

@@ -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

View File

@@ -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("[]") == []