mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +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>
82 lines
4.9 KiB
Markdown
82 lines
4.9 KiB
Markdown
# Run Interaction Policy
|
|
|
|
The harness resolves a `RunInteractionPolicy` from the run context and uses it
|
|
as the single source for lead-agent tool visibility, clarification middleware
|
|
behavior, sandbox network approval eligibility, and system-prompt guidance.
|
|
The implementation lives in `../packages/harness/deerflow/agents/interaction_policy.py`.
|
|
|
|
Interactive runs inspect available context and proceed with reversible assumptions or independent work when appropriate. Ask when essential information, consequential choices, or authorization for high-risk work is missing; a fixed clarify-plan-act sequence is not required. Clarification interrupts and trust boundaries remain unchanged.
|
|
|
|
## Modes and precedence
|
|
|
|
Interactive runs may ask a human for clarification. Runs in `scheduled`,
|
|
`webhook`, or `autonomous` mode must use the available context, make minimal
|
|
reversible assumptions, list material assumptions, or return a structured
|
|
`BLOCKED` result naming the missing decision for high-risk or irreversible
|
|
work without sufficient authorization. They must never wait for a synchronous
|
|
human response. Suppressed clarification tool results must preserve this same
|
|
boundary, not tell the model to unconditionally carry out an ambiguous action.
|
|
|
|
An explicit `interaction_mode` takes precedence over legacy context hints.
|
|
An unknown explicit mode is a configuration error, never an interactive fallback.
|
|
Without an explicit mode, the resolver checks `non_interactive` (scheduled),
|
|
then `channel_name="github"` (webhook), then `disable_clarification`
|
|
(autonomous); otherwise it selects interactive. These legacy flags remain
|
|
supported while entry points migrate to explicit modes.
|
|
|
|
Sync and async sandbox network approval paths use the same resolved policy.
|
|
Unattended runs deny pending network requests without opening a Human Input
|
|
card. Subagents always deny pending requests, even in an interactive run.
|
|
|
|
## Gateway trust boundary
|
|
|
|
`interaction_mode`, `non_interactive`, `disable_clarification`, and
|
|
`channel_name` are internal-caller-only Gateway context keys. The latter two
|
|
remain runtime-only and are never copied into checkpoint-persisted
|
|
`configurable`. Public run requests cannot use these keys to disable
|
|
clarification, impersonate a webhook channel, or override a scheduled run back
|
|
to interactive behavior.
|
|
|
|
## HITL tool approval
|
|
|
|
`hitl_approval` is an operator-controlled, opt-in gate around selected tools,
|
|
not a way for a non-interactive run to wait for clarification. Interactive
|
|
Web/SDK runs pause through LangGraph `interrupt()` and enter
|
|
`waiting_for_input`; only a matching approval response can resume the current
|
|
checkpoint. Scheduled, webhook, autonomous, IM, extension, and MCP notification
|
|
runs follow `hitl_approval.non_interactive`. Subagents and graphs without resume
|
|
support always fail closed, including when that setting is `allow`.
|
|
|
|
Resume consumption is a store-level compare-and-swap bound to the validated
|
|
action `request_id` mapping; the middleware recomputes those IDs from the live
|
|
AI message before applying any decision. If worker attachment fails after
|
|
consumption, the Gateway conditionally restores that exact answered run to
|
|
`waiting_for_input` so a valid response or all-reject can retry safely.
|
|
|
|
The resume run's journal adopts the paused calls it answers, recovered from the
|
|
durable interrupt payload. Approved calls reach the history feed through their
|
|
tool callback; rejections have none, so the journal reconciles them under the
|
|
resume run like other middleware-written results (#4666). Results from other
|
|
earlier runs are not re-attributed.
|
|
|
|
A call that cannot carry a stable approval decision is never paused. A null or
|
|
duplicated call id can never match the resume validator and would block new runs
|
|
forever; an empty id is technically matchable, but it is denied as well because
|
|
strict providers reject an empty tool-call id when the conversation is replayed.
|
|
The middleware removes those calls from the model response with a visible
|
|
rejection note instead of pausing, so the tool is never executed and the thread
|
|
stays usable. The same uniqueness rule applies when a call is denied by result:
|
|
answering a denial `ToolMessage` also answers any other call that shares its id,
|
|
so a shared id is stripped rather than answered, and the next request cannot
|
|
carry the id twice. A pause stored by an older release with a null or duplicated
|
|
id is **not** cleared by this: every resume, including all-reject, is still
|
|
rejected, so continue the work in a new thread. In-place recovery is tracked in
|
|
[#8071](https://github.com/bytedance/deer-flow/issues/8071).
|
|
|
|
`waiting_for_input` is not a terminal success or failure. Goal checks, final
|
|
artifact delivery checks, and batch continuation cancellation must not treat a
|
|
pending approval as a completed source run. Pending approvals do not expire and
|
|
block new runs on the same thread until resolved. See
|
|
[HITL Tool Approval](CONFIGURATION.md#hitl-tool-approval) for configuration and
|
|
argument-summary visibility limits.
|