6 Commits

Author SHA1 Message Date
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
ZhYQ1
314e2a3489
feat(task_tool): introduce read-only preflight validation before delegation side effects (#8120)
* feat(task_tool): introduce read-only preflight validation before delegation side effects

Add _preflight_delegation_checks() as a zero-side-effect preflight that runs before any delegation side effects (tool assembly, executor creation, background task registration). Existing inline checks for context_mode, bash host policy, and subagent_type config membership are centralized into this function.

A rejected preflight returns immediately with status='failed' and leaves state unchanged, so callers can safely retry with corrected arguments.

A reserved resume_task_id parameter is included for a future recovery feature, pointing to issue #7633 (validate before claim/cancel) to avoid the reject-after-claim anti-pattern.

* fix(task_tool): return config from preflight, drop dead resume_task_id param, soften side-effect comment

Fixes UnboundLocalError on successful delegation paths: the helper now returns (config, error) and callers destructure both. Previously config was scoped inside the helper so the happy-path references at lines 1044/1074/1082 raised UnboundLocalError. Verified against test_task_tool_core_logic.py.

Drops the unused resume_task_id parameter and its hardcoded rejection branch — dead code since the only call site never passes it, and a future recovery feature should plug real read-only recoverability checks here (see issue #7633).

Narrows the helper-side-effect claim in the caller comment: input_control.child(tool_call_id) already registers a benign retry-reusable child gate before the preflight runs, so the wording now scopes the zero-side-effect promise to the helper itself.

* test(task_tool): cover preflight abort and happy paths

Adds unit coverage for _preflight_delegation_checks (invalid context_mode, host bash disabled/enabled, subagent outside caller policy, no permitted subagents) plus a rejected-delegation test asserting no executor is created and no tools are assembled.

Adds a happy-path regression test that would have caught the UnboundLocalError: it asserts the config resolved by the preflight reaches SubagentExecutor, including the unconditional config.model == 'inherit' branch.

* fix(task_tool): resolve catalog inside preflight after guards

Copilot review flagged that catalog discovery (get_available_subagent_names) was hoisted out of the helper and ran before any guard, so an invalid context_mode could hit the managed-subagent store instead of returning status='failed'.

Now the helper takes only allowed_subagents and performs its own catalog lookup strictly after the context_mode and bash guards. The caller no longer calls get_available_subagent_names ahead of preflight.

Adds test_preflight_resolves_catalog_after_guards asserting get_available_subagent_names is never invoked when context_mode is invalid.

* test(runtime): use ToolRuntime for native task results

* docs(task_tool): translate preflight comments to English

Comment-only change; no behavior impact.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: AochenShen99 <sac1063467113@hotmail.com>
2026-11-12 18:59:04 +08:00
Evan
02df37c5de
fix(runtime): charge a steered subagent's usage to the token budget (#7776)
* fix(runtime): charge a steered subagent's usage to the token budget

A task that finishes after a Steer reports through a receipt HumanMessage, so its usage never reached the dispatching AIMessage the token budget reads. Carry the usage over when the receipt is delivered.

* fix(runtime): report deferred usage with no AIMessage to charge

Log the rare case instead of dropping the usage silently, and scan for AIMessages once per delivery.
2026-11-01 23:28:04 +08:00
Evan
be39a8a879
fix(runtime): resume children paused for a withdrawn steer (#7753)
* fix(runtime): resume children paused for a withdrawn steer

wait_tool pauses every child as soon as a steer is pending, but only a
delivered steer sets steering_active, the flag finish_graph needs before it
resumes them. Removing the steer before delivery left the children parked
and the run waiting on them.

Release the children when deliver finds no input and no steer is in
progress. They stay paused during later model turns of a delivered steer.

* fix(runtime): release children paused by a steer withdrawn mid-turn

The steering_active guard skipped the release whenever another steer was
being handled. A child started during that turn is outside its
interrupted_children snapshot, so nothing resumed it after a second steer
was withdrawn and finish_graph waited forever.

Release every child a delivered steer does not hold. Each deliver re-takes
that snapshot, so a paused child outside it can only belong to a withdrawn
steer.

* fix(runtime): keep a replacement reusing a call ID out of the held steer

interrupted_children stores call IDs, and child() can replace a closed gate under the same ID. The replacement then looked held by an earlier delivered steer, so a withdrawn steer left it paused forever in a replaced turn. Drop the ID when its gate is replaced.
2026-11-01 23:18:49 +08:00
Evan
c6a3993695
fix(runtime): keep each parallel task's gate across a steer (#7755)
wait_tool reused the name gate for its pause loop, so every parallel task
returned the last child's result id and its done callback closed that
child's gate. The message reducer then dropped all but one pending result,
and the model was told that a running subagent had been interrupted.

Rename the loop variable so each call keeps its own gate.
2026-10-31 21:40:59 +08:00
Totoro
44e372dd7f
feat(runtime): support Queue and Steer during active Web runs (#7729)
* feat(runtime): add Queue and Steer for active Web runs

* style(frontend): format Queue and Steer controls

* fix(runtime): retain user Steer controls and order active inputs

* docs: add Chinese changelog entry for Queue and Steer

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-31 11:35:36 +08:00