Harden tool execution recovery and fix multi-tab sync hangs

This commit is contained in:
ilya-bov
2026-02-27 17:06:17 +03:00
parent dcb1eabb4e
commit ce362a836b
8 changed files with 291 additions and 34 deletions

View File

@@ -66,6 +66,147 @@ function parseJsonObject(text: string): Record<string, unknown> | null {
}
}
function getOutputTextForRecovery(output: unknown): string {
if (typeof output === "string") {
return output;
}
const record = asRecord(output);
if (!record) {
return "";
}
const out = typeof record.output === "string" ? record.output : "";
const err = typeof record.error === "string" ? record.error : "";
return [out, err].filter(Boolean).join("\n");
}
function extractNodeMissingModule(text: string): string | null {
const match = text.match(/Cannot find module ['"]([^'"\n]+)['"]/i);
const mod = match?.[1]?.trim();
return mod ? mod : null;
}
function extractPythonMissingModule(text: string): string | null {
const match = text.match(/ModuleNotFoundError:\s*No module named ['"]([^'"\n]+)['"]/i);
const mod = match?.[1]?.trim();
return mod ? mod : null;
}
function extractMissingCommand(text: string): string | null {
const shellMatch = text.match(/(?:^|\n)(?:\/bin\/sh:\s*\d+:\s*)?([a-zA-Z0-9._-]+):\s*not found(?:\n|$)/i);
if (shellMatch?.[1]) {
return shellMatch[1];
}
const spawnMatch = text.match(/spawn\s+([a-zA-Z0-9._/-]+)\s+ENOENT/i);
if (spawnMatch?.[1]) {
const command = spawnMatch[1].split("/").pop();
return command ?? null;
}
return null;
}
function buildAutoRecoveryHint(toolName: string, output: unknown): string | null {
if (toolName !== "code_execution" && toolName !== "process") {
return null;
}
const text = getOutputTextForRecovery(output);
if (!text) {
return null;
}
if (
/Need to install the following packages/i.test(text) &&
/Ok to proceed\?/i.test(text)
) {
return [
"Recoverable blocker detected: interactive npx prompt is waiting for confirmation.",
"Next action: rerun with non-interactive form using `npx -y ...`, then continue polling/retrying in this turn.",
"Do not stop on this blocker.",
].join("\n");
}
if (
/npm error could not determine executable to run/i.test(text) &&
/playwright-cli/i.test(text)
) {
return [
"Recoverable blocker detected: deprecated `playwright-cli` npm package does not expose an executable.",
"Next action: run the command with `npx -y @playwright/cli ...` (or install `@playwright/cli` via install_packages and retry).",
"Do not stop on this blocker.",
].join("\n");
}
if (text.includes("Host system is missing dependencies to run browsers")) {
return [
"Recoverable blocker detected: Playwright browser system dependencies are missing.",
"Next action: run install_packages with kind=\"apt\" for the required libs (or run `npx playwright install-deps` in terminal runtime), then retry the same Playwright command in this turn.",
"Do not stop and do not ask the user to run commands manually unless installation keeps failing after corrected retries.",
].join("\n");
}
const missingNodeModule = extractNodeMissingModule(text);
if (missingNodeModule) {
return [
`Recoverable blocker detected: missing Node module "${missingNodeModule}".`,
`Next action: call install_packages with kind="node" and packages=["${missingNodeModule}"], then retry the same command in this turn.`,
"Do not stop after this error.",
].join("\n");
}
const missingPythonModule = extractPythonMissingModule(text);
if (missingPythonModule) {
return [
`Recoverable blocker detected: missing Python module "${missingPythonModule}".`,
`Next action: call install_packages with kind="python" and packages=["${missingPythonModule}"], then retry the same command in this turn.`,
"Do not stop after this error.",
].join("\n");
}
if (/playwright-cli:\s*not found/i.test(text)) {
return [
"Recoverable blocker detected: playwright-cli is not installed/in PATH.",
"Next action: first try running the same command via `npx -y @playwright/cli ...`.",
"If npx path is unavailable, call install_packages with kind=\"node\" and packages=[\"@playwright/cli\"], then retry in this turn.",
"Do not end the turn on this error.",
].join("\n");
}
const missingCommand = extractMissingCommand(text);
if (missingCommand && missingCommand !== "node" && missingCommand !== "python3") {
return [
`Recoverable blocker detected: command "${missingCommand}" is missing.`,
`Next action: install it via install_packages (kind depends on ecosystem, e.g. apt for system commands), then retry the original command in this turn.`,
"Only report blocker after corrected install attempts fail.",
].join("\n");
}
return null;
}
function appendRecoveryHint(output: unknown, hint: string | null): unknown {
if (!hint) {
return output;
}
const block = `\n\n[Auto-recovery hint]\n${hint}`;
if (typeof output === "string") {
return `${output}${block}`;
}
const record = asRecord(output);
if (!record) {
return output;
}
const current = typeof record.output === "string" ? record.output : "";
return {
...record,
output: current ? `${current}${block}` : block.trim(),
recoverable: true,
recoveryHint: hint,
};
}
function extractDeterministicFailureSignature(output: unknown): string | null {
const outputRecord = asRecord(output);
if (outputRecord && outputRecord.success === false) {
@@ -153,7 +294,7 @@ function normalizeNoProgressValue(value: unknown): unknown {
}
function applyGlobalToolLoopGuard(tools: ToolSet): ToolSet {
const deterministicFailureByCall = new Map<string, string>();
let lastDeterministicFailure: { callKey: string; signature: string } | null = null;
const noProgressByCall = new Map<string, { hash: string; count: number }>();
const wrappedTools: ToolSet = {};
@@ -185,25 +326,29 @@ function applyGlobalToolLoopGuard(tools: ToolSet): ToolSet {
);
}
const previousFailure = deterministicFailureByCall.get(callKey);
if (previousFailure) {
if (lastDeterministicFailure?.callKey === callKey) {
return (
`[Loop guard] Blocked repeated tool call "${toolName}" with identical arguments.\n` +
`Previous deterministic error: ${previousFailure}\n` +
`Previous deterministic error: ${lastDeterministicFailure.signature}\n` +
"Change arguments based on the tool error before retrying."
);
}
const output = await toolDef.execute(input as never, options as never);
const failureSignature = extractDeterministicFailureSignature(output);
const recoveryHint = buildAutoRecoveryHint(toolName, output);
const outputWithHint = appendRecoveryHint(output, recoveryHint);
const failureSignature = extractDeterministicFailureSignature(outputWithHint);
if (failureSignature) {
deterministicFailureByCall.set(callKey, failureSignature);
lastDeterministicFailure = {
callKey,
signature: failureSignature,
};
} else {
deterministicFailureByCall.delete(callKey);
lastDeterministicFailure = null;
}
if (isPollLikeCall(toolName, input)) {
const outputHash = stableSerialize(normalizeNoProgressValue(output));
const outputHash = stableSerialize(normalizeNoProgressValue(outputWithHint));
const previous = noProgressByCall.get(callKey);
if (previous && previous.hash === outputHash) {
noProgressByCall.set(callKey, {
@@ -220,7 +365,7 @@ function applyGlobalToolLoopGuard(tools: ToolSet): ToolSet {
noProgressByCall.delete(callKey);
}
return output;
return outputWithHint;
},
} as typeof toolDef;
}

View File

@@ -51,6 +51,27 @@ interface TelegramRuntimeData {
chatId: string | number;
}
function getCurrentUserMessageText(context: AgentContext): string {
const value = context.data?.currentUserMessage;
return typeof value === "string" ? value.trim() : "";
}
function userExplicitlyRequestedProcessKill(context: AgentContext): boolean {
const text = getCurrentUserMessageText(context);
if (!text) return false;
const killIntent =
/\b(stop|terminate|kill|cancel|abort|end|прервать|прерви|остановить|останови|убить|убей|завершить|заверши|отменить|отмени)\b/i;
const negatedIntent =
/\b(do not|don't|dont|не)\b.{0,20}\b(stop|terminate|kill|cancel|abort|прерв|останов|убива|заверш|отмен)\b/i;
if (negatedIntent.test(text)) {
return false;
}
return killIntent.test(text);
}
function getTelegramRuntimeData(context: AgentContext): TelegramRuntimeData | null {
const raw = context.data?.telegram;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
@@ -776,6 +797,13 @@ export function createAgentTools(
if (!session_id?.trim()) {
return { success: false, error: "session_id is required for kill." };
}
if (!userExplicitlyRequestedProcessKill(context)) {
return {
success: false,
error:
"Kill blocked by policy: only stop a background process when the user explicitly asks to stop/cancel it. Continue with poll/log or wait for completion.",
};
}
return killManagedProcessSession(session_id);
}
if (action === "remove") {