Evan b4410f64d4
fix(agents): keep the delegation ledger exact across HITL approval and reused call ids (#8128)
* fix(agents): count each delegation once when a provider reuses its call id

Tool-call ids are unique per response, not per conversation. Some providers
number every response's calls from call_0, so two task calls in one run can
share an id.

The delegation ledger used (run_id, tool_call_id) as a delegation's identity:

- extract_delegations built one id -> entry map over the whole run, so a
  later call_0 replaced the earlier delegation's description and took its
  result while it was still running.
- merge_delegations replaced the same (run_id, id), keeping one entry.
- SubagentLimitMiddleware counted distinct ids, so the per-run cap
  (subagents.max_total_per_run) never advanced past the first call_0 and a
  run could launch any number of subagents one response at a time.

Each entry now also records dispatch_message_id, the AIMessage that issued the
call, and results pair through pair_tool_call_results, which answers a call
only from its own response. The reducer, DurableContext's change filter and
the cap use the extended identity. An entry without a dispatch id still
updates its run's entry for that call id, and a dispatch-scoped entry adopts a
ledger entry written before the field existed, so runs in flight across the
upgrade are not counted twice. The steer path names the dispatching AIMessage
from the tool request's state, so its delegation update and the graph's own
capture stay one entry.

* fix(agents): close an earlier run's dangling dispatch by its own response

_close_delegations_left_by_earlier_runs decided "answered" by
(run_id, tool_call_id). When a stopped run had dispatched call_0 twice,
the first reply kept the second, dangling entry in_progress, so the
lead kept hearing "already delegated" for work nothing would finish.

Entries that carry dispatch_message_id are now decided by pairing that
response's calls through pair_tool_call_results. Legacy entries and
dispatches no longer in history keep the id-based inference.

Also pin that two runs reusing call_0 stay two ledger entries, and
cross-reference the three delegation identity helpers.

* fix(agents): settle an approval-gated task delegation in the ledger

With `task` in `hitl_approval.tools`, the paused run records the dispatch
as in_progress and the resume run executes it. The resume run opens with
no HumanMessage, so its capture window starts after the pre-existing
messages and never contains the dispatching AIMessage: the result was
dropped and the entry stayed in_progress with no result_brief, forever.

DurableContext now pairs results the resumed window receives with the
last pre-existing dispatch and settles the issuing run's entry, keeping
its run_id instead of re-tagging it. HITL denials of `task` carry
`cancelled` subagent status, so a rejected or denied delegation settles
too.

* fix(agents): address review on the delegation ledger

- Steer path: hold each wait's dispatch AIMessage locally and pass it to
  the observer, so a later response reusing the call id while the first
  call is still observed cannot take over its args or dispatch id.
- New-turn close: a dispatch whose reply carries subagent status settles
  from that reply instead of staying in_progress, covering a crash
  between the tools checkpoint and the next capture.
- Reuse thread_state.delegation_key instead of a duplicate identity
  helper in DurableContext.
- CHANGELOG: reference #8128.

* docs(changelog): drop the #8128 entry per review
2026-11-12 19:46:58 +08:00
..