Skip to content

Commit 82b01ac

Browse files
authored
fix(reflect): unwrap JSON answer envelopes (#2345)
* fix(reflect): unwrap JSON answer envelopes * fix(reflect): clarify leaked done argument recovery
1 parent 962140e commit 82b01ac

2 files changed

Lines changed: 137 additions & 4 deletions

File tree

hindsight-api-slim/hindsight_api/engine/reflect/agent.py

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,87 @@ def _is_done_tool(name: str) -> bool:
9090
r'\s*```(?:json)?\s*\{[^}]*(?:"(?:observation_ids|memory_ids|mental_model_ids)"|\})\s*```\s*$',
9191
re.DOTALL | re.IGNORECASE,
9292
)
93-
_LEAKED_JSON_OBJECT = re.compile(
94-
r'\s*\{[^{]*"(?:observation_ids|memory_ids|mental_model_ids|answer)"[^}]*\}\s*$', re.DOTALL
95-
)
9693
_TRAILING_IDS_PATTERN = re.compile(
9794
r"\s*(?:observation_ids|memory_ids|mental_model_ids)\s*[=:]\s*\[.*?\]\s*$", re.DOTALL | re.IGNORECASE
9895
)
96+
_JSON_CODE_FENCE_PATTERN = re.compile(r"^\s*```(?:json)?\s*(\{.*\})\s*```\s*$", re.DOTALL | re.IGNORECASE)
97+
98+
_DONE_ARGUMENT_KEYS = frozenset(
99+
{
100+
"answer",
101+
"directive_compliance",
102+
"memory_ids",
103+
"mental_model_ids",
104+
"observation_ids",
105+
"model_ids",
106+
}
107+
)
108+
_DONE_ARGUMENT_MARKER_KEYS = _DONE_ARGUMENT_KEYS - {"answer"}
109+
_LEAKED_JSON_ID_KEYS = frozenset({"memory_ids", "mental_model_ids", "observation_ids", "model_ids"})
110+
111+
112+
def _unwrap_leaked_done_arguments(text: str) -> str | None:
113+
"""Return the answer when a done tool call was rendered as JSON text.
114+
115+
Some providers leak the done tool's argument object instead of surfacing it
116+
as a native tool call, e.g. {"answer": "...", "memory_ids": [...]}. Only
117+
unwrap objects that match the done argument shape so normal JSON answers
118+
stay intact.
119+
"""
120+
candidate = text.strip()
121+
if not candidate:
122+
return None
123+
124+
fenced = _JSON_CODE_FENCE_PATTERN.match(candidate)
125+
if fenced:
126+
candidate = fenced.group(1).strip()
127+
128+
try:
129+
payload = json.loads(candidate)
130+
except json.JSONDecodeError:
131+
return None
132+
133+
if not isinstance(payload, dict):
134+
return None
135+
answer = payload.get("answer")
136+
if not isinstance(answer, str) or not answer.strip():
137+
return None
138+
139+
keys = set(payload)
140+
if not keys.intersection(_DONE_ARGUMENT_MARKER_KEYS):
141+
return None
142+
if not keys.issubset(_DONE_ARGUMENT_KEYS):
143+
return None
144+
145+
for key in ("memory_ids", "mental_model_ids", "observation_ids", "model_ids"):
146+
value = payload.get(key)
147+
if value is not None and not isinstance(value, list):
148+
return None
149+
150+
return answer.strip()
151+
152+
153+
def _strip_trailing_id_json_object(text: str) -> str:
154+
stripped = text.rstrip()
155+
if not stripped.endswith("}"):
156+
return text.strip()
157+
158+
start = stripped.rfind("{")
159+
if start < 0:
160+
return text.strip()
161+
162+
try:
163+
payload = json.loads(stripped[start:])
164+
except json.JSONDecodeError:
165+
return text.strip()
166+
167+
if not isinstance(payload, dict) or not payload:
168+
return text.strip()
169+
keys = set(payload)
170+
if not keys.issubset(_LEAKED_JSON_ID_KEYS):
171+
return text.strip()
172+
173+
return stripped[:start].strip()
99174

100175

101176
def _clean_answer_text(text: str) -> str:
@@ -104,6 +179,10 @@ def _clean_answer_text(text: str) -> str:
104179
Some LLMs output the done() call as text instead of a proper tool call.
105180
This strips out patterns like: done({"answer": "...", ...})
106181
"""
182+
unwrapped = _unwrap_leaked_done_arguments(text)
183+
if unwrapped is not None:
184+
return unwrapped
185+
107186
# Remove done() call pattern from the end of the text
108187
cleaned = _DONE_CALL_PATTERN.sub("", text).strip()
109188
return cleaned if cleaned else text
@@ -122,13 +201,17 @@ def _clean_done_answer(text: str) -> str:
122201
if not text:
123202
return text
124203

204+
unwrapped = _unwrap_leaked_done_arguments(text)
205+
if unwrapped is not None:
206+
return unwrapped
207+
125208
cleaned = text
126209

127210
# Remove leaked JSON in code blocks at the end
128211
cleaned = _LEAKED_JSON_SUFFIX.sub("", cleaned).strip()
129212

130213
# Remove leaked raw JSON objects at the end
131-
cleaned = _LEAKED_JSON_OBJECT.sub("", cleaned).strip()
214+
cleaned = _strip_trailing_id_json_object(cleaned)
132215

133216
# Remove trailing ID patterns
134217
cleaned = _TRAILING_IDS_PATTERN.sub("", cleaned).strip()

hindsight-api-slim/tests/test_reflect_agent.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,25 @@ def test_clean_text_multiline_done(self):
6969
cleaned = _clean_answer_text(text)
7070
assert cleaned == "Summary of findings."
7171

72+
def test_clean_text_recovers_leaked_done_arguments(self):
73+
"""A done tool-call argument object rendered as text should keep only answer."""
74+
text = """{
75+
"answer": "Use the inbound table for API consumers.",
76+
"directive_compliance": "Directive 1 followed.",
77+
"memory_ids": ["mem-1"],
78+
"mental_model_ids": [],
79+
"observation_ids": []
80+
}"""
81+
cleaned = _clean_answer_text(text)
82+
assert cleaned == "Use the inbound table for API consumers."
83+
assert "directive_compliance" not in cleaned
84+
85+
def test_clean_text_leaves_non_done_json_answer_unchanged(self):
86+
"""Plain JSON answers are valid user-visible content."""
87+
text = '{"status": "ok", "items": [1, 2]}'
88+
cleaned = _clean_answer_text(text)
89+
assert cleaned == text
90+
7291

7392
class TestCleanDoneAnswer:
7493
"""Test cleanup of answer field from done() tool call that leaks structured output."""
@@ -142,6 +161,37 @@ def test_clean_answer_multiline_with_markdown(self):
142161
assert "Point 2" in cleaned
143162
assert "mental_model_ids" not in cleaned
144163

164+
def test_clean_answer_recovers_leaked_done_arguments(self):
165+
"""A done answer that contains leaked done arguments should keep answer content."""
166+
text = """{
167+
"answer": "Render two markdown tables: inbound and outbound.",
168+
"directive_compliance": "All directives followed.",
169+
"memory_ids": ["mem-1", "mem-2"],
170+
"mental_model_ids": [],
171+
"observation_ids": ["obs-1"]
172+
}"""
173+
cleaned = _clean_done_answer(text)
174+
assert cleaned == "Render two markdown tables: inbound and outbound."
175+
176+
def test_clean_answer_recovers_fenced_leaked_done_arguments(self):
177+
"""Some providers put leaked done arguments in a JSON code fence."""
178+
text = """```json
179+
{
180+
"answer": "The current interface is HTTP only.",
181+
"memory_ids": [],
182+
"mental_model_ids": [],
183+
"observation_ids": []
184+
}
185+
```"""
186+
cleaned = _clean_done_answer(text)
187+
assert cleaned == "The current interface is HTTP only."
188+
189+
def test_clean_answer_rejects_done_arguments_with_unexpected_keys(self):
190+
"""Avoid rewriting user-requested JSON that happens to contain answer."""
191+
text = '{"answer": "yes", "payload": {"format": "json"}, "memory_ids": []}'
192+
cleaned = _clean_done_answer(text)
193+
assert cleaned == text
194+
145195

146196
class TestToolNameNormalization:
147197
"""Test tool name normalization for various LLM output formats."""

0 commit comments

Comments
 (0)