mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 14:00:35 +00:00
* fix(hitl): never pause on an approval call without a usable id A provider can emit a tool call with no id (langchain_core keeps `id=None`), an empty id, or the same id twice in one response. `HitlApprovalMiddleware` copied that id into the interrupt payload, but the Gateway resume validator requires unique non-empty strings: every resume answered 422 and every new run stayed 409 `approval_pending`, so the thread was wedged permanently — the all-reject escape is validated after that check. Denial paths also built `ToolMessage(tool_call_id=None)`, which raises inside `after_model`, so a non-interactive run failed instead of denying the call. Treat such calls as unaddressable: strip them from the model response with a visible rejection note instead of pausing, and deny by removal whenever a `ToolMessage` could not pair. The tool never executes and the thread stays usable. Fixes #8069 * docs(hitl): state the empty-id rule accurately and track legacy recovery Review follow-up on the unaddressable-approval guard: - An empty tool-call id was technically resumable end to end (langchain-core accepts `ToolMessage("")`, and the Gateway validator accepts `""`), so only a null or duplicated id can wedge a pause. The rule still denies empty ids because strict providers reject them when the conversation is replayed; the docstring, CHANGELOG and interaction policy now say exactly that, and `_process_responses` uses the same `_addressable` predicate as the pause guard so the two checks cannot drift. - Add an operator note for pauses stored before this fix: they are not cleared here, so continue in a new thread; in-place all-reject recovery is tracked in #8071. * fix(hitl): drop a colliding call id from every serialized surface Review follow-up: `_strip_calls` removed the unaddressable approval calls from `tool_calls`, but `clone_ai_message_with_tool_calls` filters the raw `additional_kwargs["tool_calls"]` payload and content tool-call blocks by id. A non-target call sharing a removed id kept that id in `kept_ids`, so the removed calls stayed in the raw provider payload and the next request would advertise calls that have no result. Drop the whole colliding id group: an id shared with a denied approval call cannot be answered unambiguously either. The regression covers a mixed response (duplicate approval ids plus an ordinary call with the same id) and asserts the raw payload, content blocks, execution and visible note. * fix(hitl): reject an id shared with an ordinary call before pausing Review follow-up: `_addressable(targets)` only asked whether a `ToolMessage` can answer the targeted calls. A single approval-required call whose id is shared by an ordinary call in the same response still paused, even though approving or rejecting it would also answer the ordinary call by id. Add `_unambiguous_ids`: every targeted id must also be unique across the whole response. A collision denies the colliding group (strip + visible note) before the pause, and the resume path applies the same check to a legacy pause. New regression: one approval-required call and one ordinary call sharing an id. * fix(hitl): strip a deny target whose id an ordinary call shares `_deny_all` routed only on `_addressable`, which checks uniqueness among the targeted calls. A target whose id an ordinary non-target call also carried was therefore answered with a deny `ToolMessage` while the ordinary call stayed serialized, so the next request advertised the id twice with two results. `_deny_all` now requires `_unambiguous_ids` and strips the whole colliding id group, matching the pause path. The tool still never executes. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>