fix(reflect): safely strip leaked done-tool siblings - #2972
Conversation
…swer
A rebuilt mental model was persisted with 17 memory UUIDs and a trailing JSON
object appended to its content. The answer string had been closed and its
sibling done-arguments followed it:
...Admin services inaccessible except via encrypted tunnel", "memory_ids":
["97424a2f-...", ... ]
}
None of the four existing mechanisms catches that shape, each for a different
reason:
- _unwrap_leaked_done_arguments requires the whole text to parse as JSON, but
the opening {"answer": " was consumed as the answer's start, so the remainder
is not parseable.
- _DONE_CALL_PATTERN requires a literal done( in the text.
- _LEAKED_JSON_SUFFIX requires a ``` fence.
- _strip_trailing_id_json_object requires the run to begin at "{".
- _TRAILING_IDS_PATTERN requires an UNQUOTED key and end-of-string after the
"]"; here the key is quoted and a "}" follows.
_LEAKED_SIBLING_FIELDS_PATTERN matches a well-formed run of trailing JSON
fields -- an id list followed by any number of "key": <json-value> pairs and an
optional closing brace -- anchored to end of string. Requiring that shape is
what keeps it from eating prose: ordinary text does not end that way, and a
fenced JSON example mid-document is untouched.
The stripper runs only when the text does not parse as JSON. A complete JSON
document is either a real done-argument object, already handled upstream by
_unwrap_leaked_done_arguments (which deliberately declines objects with
unexpected keys), or a JSON answer the caller actually requested. Without that
gate this pattern rewrote the existing
test_clean_answer_rejects_done_arguments_with_unexpected_keys fixture, whose
last field is an empty id list -- truncation, not the presence of an id field,
is what identifies the leak.
Three regressions added: the exact production shape, multiple sibling id fields
with and without a closing brace, and prose that merely mentions memory_ids or
embeds a fenced JSON example. Verified by mutation -- disabling the gate fails
two of them. Suite: 63 passed, same 3 pre-existing errors as unmodified main
(they require live LLM credentials).
|
Coordination note: #2960 and this PR were both cut from the same The safe composed ordering is:
Please do not compute the failure reason from |
… text Reflect is driven by structured tool calls. Some provider transports don't actually support function calling and silently strip the tool definitions from the request (e.g. litellm's Vertex AI gpt-oss MaaS path drops tools/tool_choice when the model is flagged unsupported). The model then answers in free text that mimics a done() payload, which landed in message.content with empty tool_calls. The old code served that raw text as the answer, so a growing pile of regex/JSON "strippers" tried to claw the leaked memory_ids/observation_ids/directive_compliance siblings back out of the user-facing answer. Instead of salvaging untooled text, fail loudly: - Track whether the model ever produced a tool call reflect could parse. If it never does (the stripped-tools case), raise ReflectToolCallError -> HTTP 500 (the request is valid; the server's configured model can't do the job) with a clear message (provider, model, response snippet). - Keep the done tool; _process_done_tool now trusts args["answer"] verbatim. A parsed tool call can't bleed its sibling id fields into the answer string. - A model that DID tool-call and later stops with text is a legitimate stop and still routes through the clean forced-final synthesis path. - Delete the entire strip zoo: _clean_done_answer, _unwrap_leaked_done_arguments, _strip_trailing_id_json_object, _clean_answer_text, _DONE_CALL_PATTERN, and the leaked-JSON regexes/key-sets. The forced-final paths return the model's prose directly (tools are disabled there, so there is no tool syntax to strip). No static supports_function_calling gate -- reflect just tries and fails. Supersedes the answer-salvage approach in #2972.
|
Superseded by #3013, which changes the approach: instead of adding more salvage logic to strip leaked |
… text Reflect is driven by structured tool calls. Some provider transports don't actually support function calling and silently strip the tool definitions from the request (e.g. litellm's Vertex AI gpt-oss MaaS path drops tools/tool_choice when the model is flagged unsupported). The model then answers in free text that mimics a done() payload, which landed in message.content with empty tool_calls. The old code served that raw text as the answer, so a growing pile of regex/JSON "strippers" tried to claw the leaked memory_ids/observation_ids/directive_compliance siblings back out of the user-facing answer. Instead of salvaging untooled text, fail loudly: - Track whether the model ever produced a tool call reflect could parse. If it never does (the stripped-tools case), raise ReflectToolCallError -> HTTP 500 (the request is valid; the server's configured model can't do the job) with a clear message (provider, model, response snippet). - Keep the done tool; _process_done_tool now trusts args["answer"] verbatim. A parsed tool call can't bleed its sibling id fields into the answer string. - A model that DID tool-call and later stops with text is a legitimate stop and still routes through the clean forced-final synthesis path. - Delete the entire strip zoo: _clean_done_answer, _unwrap_leaked_done_arguments, _strip_trailing_id_json_object, _clean_answer_text, _DONE_CALL_PATTERN, and the leaked-JSON regexes/key-sets. The forced-final paths return the model's prose directly (tools are disabled there, so there is no tool syntax to strip). No static supports_function_calling gate -- reflect just tries and fails. Supersedes the answer-salvage approach in #2972.
… text (#3013) * fix(reflect): fail on unusable tool calls instead of salvaging leaked text Reflect is driven by structured tool calls. Some provider transports don't actually support function calling and silently strip the tool definitions from the request (e.g. litellm's Vertex AI gpt-oss MaaS path drops tools/tool_choice when the model is flagged unsupported). The model then answers in free text that mimics a done() payload, which landed in message.content with empty tool_calls. The old code served that raw text as the answer, so a growing pile of regex/JSON "strippers" tried to claw the leaked memory_ids/observation_ids/directive_compliance siblings back out of the user-facing answer. Instead of salvaging untooled text, fail loudly: - Track whether the model ever produced a tool call reflect could parse. If it never does (the stripped-tools case), raise ReflectToolCallError -> HTTP 500 (the request is valid; the server's configured model can't do the job) with a clear message (provider, model, response snippet). - Keep the done tool; _process_done_tool now trusts args["answer"] verbatim. A parsed tool call can't bleed its sibling id fields into the answer string. - A model that DID tool-call and later stops with text is a legitimate stop and still routes through the clean forced-final synthesis path. - Delete the entire strip zoo: _clean_done_answer, _unwrap_leaked_done_arguments, _strip_trailing_id_json_object, _clean_answer_text, _DONE_CALL_PATTERN, and the leaked-JSON regexes/key-sets. The forced-final paths return the model's prose directly (tools are disabled there, so there is no tool syntax to strip). No static supports_function_calling gate -- reflect just tries and fails. Supersedes the answer-salvage approach in #2972. * test(mock): drive the reflect loop via tool calls, not bare prose The reflect agent now rejects a turn that yields no usable tool call (ReflectToolCallError). MockLLM's default call_with_tools returned bare "mock response" content with no tool calls, which the old salvage path served as the answer -- so ~15 reflect integration tests (empty-bank, tracing, based_on, tags, think) started failing with 500 under the new guard. Make MockLLM simulate a compliant tool-calling provider in its default path: honor a forced retrieval tool_choice (so recall/search actually run and populate based_on), and otherwise finish via the done tool. Tests that script their own turns via _response_callback / _mock_response are unaffected.
Why
Follow-up to #2298. Some providers can close the
answerstring and then leak siblingdonearguments such asmemory_ids,observation_ids,mental_model_ids, ordirective_complianceinto the returned answer text. The existing cleanup handled complete JSON envelopes and a few textual patterns, but missed truncated sibling fields after a quoted answer. A broad regex would also risk deleting legitimate prose that merely discusses those field names.What changed
NO_ANSWER_TEXTfailure guardChecks
uv run --frozen ruff check hindsight_api/engine/reflect/agent.py tests/test_reflect_agent.pyuv run --frozen ruff format --check hindsight_api/engine/reflect/agent.py tests/test_reflect_agent.pyuv run --frozen pytest tests/test_reflect_agent.py -k TestCleanDoneAnswer -q— 20 passedscripts/hooks/lint.shwas also run: the changed files passed their scoped Ruff checks, while unrelated control-plane ESLint failed because@eslint/jsis absent and Windowstyreported existing Unix/MLX-only importsNo public API or schema changes.