Files
qwen-code/packages/cli/src/ui/hooks/ink-tool-executor.ts
T
qqqys 07a4fda666 feat(cli): run the Ink TUI's tool batches on the shared loop's batch (#16304)
* feat(cli): run the Ink TUI's tool batches on the shared loop's batch

With experimental.unifiedAgentLoop on, the tool calls of each Ink TUI model
round run as one batch of the shared loop, runToolBatch, on an executor over
the TUI's own scheduler. The tool group, approvals, Esc and the Mod tool
middleware are unchanged; the batch's completion goes back to the executor,
and the TUI's completion step continues the turn with the batch's results.

- useReactToolScheduler's schedule takes an optional onBatchComplete that
  receives that batch's completion in place of onComplete.
- createInkToolExecutor implements AgentLoopToolExecutor over it and marks
  the calls the Mod tool middleware already counted, through a new optional
  `recorded` on the loop's settled call.
- runToolBatch also returns its finalized parts by call, `responses`, so the
  completion step can leave out results it does not send.
- The scheduler's completion handling moved into commitCompletedToolBatch,
  which both paths call; handleCompletedTools skips the steps the shared
  batch did. A response made only of provider replays keeps the current path.
- The Ink parity suite counts shared tool batches through the debug log and
  adds a replayed call next to a fresh one, an approved then declined write,
  and a shell command cancelled with Esc.
- The setting's description and the design doc (3b split into 3b-1 and 3b-2,
  with 3b-1's intended difference) are updated in both languages.

* fix(cli): send and record only the shared tool batch results the TUI keeps

- runToolBatch takes `deferRecording` and returns each result's recording,
  so the Ink TUI records only what it sends, after its session check; a
  result that replaces a placeholder is recorded once, and dropped or
  secondary-owner results are not recorded.
- A shared batch that completes during a model stream is queued like the
  current path's batches and handled when the streams end, against history
  as it is then, in one continuation with the other waiting batches.
- Calls the shared batch ran are not counted again by the history-dedup and
  secondary-owner steps.
- The batch's replay answers keep the continuation going when every
  executed call was cancelled.
- A failed shared batch falls back to the current completion path for its
  settled calls; a schedule that ends without completing releases the
  batch's callback.
- Provider call fingerprints are recorded when the batch is scheduled.

* fix(cli): keep a shared tool batch in the session and render it completed in

- Take the session and chat when the scheduler completes the batch, before
  it is finalized, so a /clear or /resume while a large result is persisted
  no longer sends or records that result in the new session.
- Commit the tool group inside the scheduler's completion callback, in the
  render that clears its display list, instead of after the finalize.
- Leave cancelled calls to the TUI to count, so a cancelled call whose id
  history already answered is not counted.
- Merge queued shared batches' results one per call id.
- When the batch throws, drop the replay ids it marked as answered but
  never sent.

* fix(cli): continue a shared tool batch with the TUI's current state and settle it when its completion throws

- runSharedToolBatch commits through commitCompletedToolBatchRef and reads the
  chat from the client the TUI holds now, so a batch that waited on approvals
  or long tools continues with the current handleCompletedTools, submitQuery,
  quota fallback and client, as the scheduler's onComplete ref does.
- The Ink executor resolves the batch in a finally block, and the session
  capture in onSettled no longer throws: a chat that cannot be read (a reset
  under way) ends the batch without a continuation and releases the turn's
  activity instead of leaving it Responding.
2026-11-05 02:47:31 +00:00

75 lines
2.7 KiB
TypeScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type {
AgentLoopSettledCall,
AgentLoopToolExecutor,
} from '@qwen-code/qwen-code-core/core/agent-loop.js';
import type { InteractiveToolCall } from '../../services/interactive-mod-tool-scheduler.js';
import type { ScheduleFn } from './useReactToolScheduler.js';
/**
* Runs the shared loop's tool batches on the TUI's scheduler, so the tool
* group, its approvals and the Mod tool middleware stay as they are.
*
* The scheduler queues a batch as one, so each call is offered to the loop's
* `beforeLaunch` when the batch is scheduled rather than right before its own
* launch; the TUI refuses no launch there.
*
* `onSettled` runs as soon as the scheduler completes the batch, before the
* loop finalizes its results, with the calls as the scheduler reported them.
* It runs inside the scheduler's completion callback, right after the
* scheduler cleared its display list, so a tool group committed there lands
* in the same render as the clear (#9420). The batch settles even if it
* throws.
*/
export function createInkToolExecutor(
schedule: ScheduleFn,
modelOverride: string | undefined,
onSettled: (calls: InteractiveToolCall[]) => void,
): AgentLoopToolExecutor {
return {
run: (requests, signal, hooks) => {
const launched = requests.filter((request) =>
hooks.beforeLaunch(request, []),
);
if (launched.length === 0) return Promise.resolve([]);
return new Promise<AgentLoopSettledCall[]>((resolve) => {
schedule(launched, signal, modelOverride, async (calls) => {
// The batch settles even when `onSettled` throws, so the loop
// finalizes it and its caller releases what it holds.
try {
onSettled(calls);
} finally {
resolve(
calls.flatMap((call): AgentLoopSettledCall[] =>
call.status === 'success' ||
call.status === 'error' ||
call.status === 'cancelled'
? [
{
status: call.status,
request: call.request,
response: call.response,
// The Mod tool middleware counts each native
// attempt, and the TUI counts a cancelled call only
// where the current path does (not when history
// answered it).
recorded:
call.nativeAttemptsObserved === true ||
call.status === 'cancelled',
},
]
: [],
),
);
}
});
});
},
};
}