* 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>
4.9 KiB
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.
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 for configuration and
argument-summary visibility limits.