mirror of
https://github.com/eggent-ai/eggent.git
synced 2026-04-26 19:26:09 +00:00
Harden tool execution recovery and fix multi-tab sync hangs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user