qwen-code/packages/cli/index.ts
euxaristia 5facd8738b
feat(core): detect tool validation retry loops and inject stop directive (#3178)
Primary change: prevent the model from burning tokens in an infinite retry
loop when a tool call repeatedly fails schema validation with the same
error (observed with ask_user_question and a malformed `questions`
parameter retrying 10+ times with the same validation error).

- Track consecutive validation failures per (tool name, error message)
  pair in CoreToolScheduler via a `validationRetryCounts` Map.
- After 3 consecutive failures for the same (tool, error) pair, append a
  RETRY LOOP DETECTED directive to the error response instructing the
  model to stop, re-examine the schema, try a fundamentally different
  approach, or surface the issue to the user.
- Reset per-tool counters when the tool invocation succeeds; reset
  globally when an incoming batch shares no tool name with any
  previously failing tool; reset the per-tool counter when the tool
  returns a different validation error so unrelated mistakes do not
  accumulate toward the threshold.
- Distinct from LoopDetectionService, which tracks model-behavior loops
  (repeated thoughts, stagnant actions); this change catches tool-API
  misuse loops at the scheduler layer.

Piggyback fixes bundled in the same PR:

- packages/cli/index.ts, packages/core/src/services/shellExecutionService.ts:
  treat PTY `EAGAIN` on the read path as an expected read error alongside
  `EIO`, avoiding noisy surface-level failures from transient
  non-blocking reads.
- scripts/build.js: switch the settings-schema generation step from
  `npx tsx` to `node --import tsx/esm` for Bun compatibility.

Tests:

- Unit tests in coreToolScheduler.test.ts cover: directive injection on
  the 3rd consecutive failure, counter reset when a different tool is
  called, and counter reset after a successful invocation of the same
  tool (fail → fail → succeed → fail → fail must not trip the directive).
2026-04-18 10:24:46 +08:00

102 lines
2.6 KiB
JavaScript

#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { initStartupProfiler } from './src/utils/startupProfiler.js';
// Must run before any other imports to capture the earliest possible T0.
initStartupProfiler();
import './src/gemini.js';
import { main } from './src/gemini.js';
import { FatalError } from '@qwen-code/qwen-code-core';
import { writeStderrLine } from './src/utils/stdioHelpers.js';
// --- Global Entry Point ---
// Suppress known race conditions in @lydell/node-pty.
//
// PTY errors that are expected due to timing races between process exit
// and I/O operations. These should not crash the app.
//
// References:
// - https://github.com/microsoft/node-pty/issues/178 (EIO on macOS/Linux)
// - https://github.com/microsoft/node-pty/issues/827 (resize on Windows)
const getErrnoCode = (error: unknown): string | undefined => {
if (!error || typeof error !== 'object') {
return undefined;
}
const code = (error as { code?: unknown }).code;
return typeof code === 'string' ? code : undefined;
};
const isExpectedPtyRaceError = (error: unknown): boolean => {
if (!(error instanceof Error)) {
return false;
}
const message = error.message;
const code = getErrnoCode(error);
// EIO: PTY read race on macOS/Linux - code + PTY context required
// https://github.com/microsoft/node-pty/issues/178
if (
(code === 'EIO' && message.includes('read')) ||
message.includes('read EIO')
) {
return true;
}
// EAGAIN: transient non-blocking read error from PTY fd
if (
(code === 'EAGAIN' && message.includes('read')) ||
message.includes('read EAGAIN')
) {
return true;
}
// PTY-specific resize/exit race errors - require PTY context in message
if (
message.includes('ioctl(2) failed, EBADF') ||
message.includes('Cannot resize a pty that has already exited')
) {
return true;
}
return false;
};
process.on('uncaughtException', (error) => {
if (isExpectedPtyRaceError(error)) {
return;
}
if (error instanceof Error) {
writeStderrLine(error.stack ?? error.message);
} else {
writeStderrLine(String(error));
}
process.exit(1);
});
main().catch((error) => {
if (error instanceof FatalError) {
let errorMessage = error.message;
if (!process.env['NO_COLOR']) {
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
}
console.error(errorMessage);
process.exit(error.exitCode);
}
console.error('An unexpected critical error occurred:');
if (error instanceof Error) {
console.error(error.stack);
} else {
console.error(String(error));
}
process.exit(1);
});