diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index dd31d672..2ccc1a2d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -926,22 +926,27 @@ public class AgentGraphBuilder { If you try to read a PDF/Office file with read_file, you will get binary garbage or an error. """.formatted(entity.getId()); - String searchGuidance = ""; - if (builtinSearchEnabled) { - searchGuidance = """ + // Web-search vs browser_use priority guidance — emitted unconditionally so the rule + // also reaches OpenAI-compatible / Anthropic / Gemini / DeepSeek / Ollama agents that + // do not have builtin search. Issue #40: without this rule the model treats + // browser_use as a search tool and gets stuck in a Playwright launch loop on Windows. + String searchGuidance = """ ## Web Search Capability - You have **dual search capability**: - 1. **Built-in search** (preferred): Your responses automatically incorporate live web search results from the model provider. For most queries, answer directly — your response already includes real-time search data. - 2. **search tool** (supplementary): Available as a fallback. Supports advanced parameters: `freshness` (day/week/month/year), `language` (zh-CN/en), `count` (1-10). - - ### Priority Rules - - **Default**: Answer directly using built-in search. Do NOT say you cannot search — your replies already include live results. - - **Use search tool** ONLY when: you need precise time filtering (e.g., user asks for "yesterday's news" → call search with freshness=day), specific language results, or your built-in results feel insufficient. - - **NEVER** call both browser_use and search tool for the same query. + ### Tool Priority + - For plain web search or fetching public page content, call the `search` tool. It supports advanced parameters: `freshness` (day/week/month/year), `language` (zh-CN/en), `count` (1-10). + - Call `browser_use` ONLY when you need to interact with a page (click, fill forms, screenshot, run JS, follow a logged-in flow). Do NOT use `browser_use` as a search alternative. + - **NEVER** call both `browser_use` and `search` for the same query. - When searching for news, use the standard format: `📰 [Category] Title — Source | Time + Summary`, up to 5 results per category. """; + if (builtinSearchEnabled) { + searchGuidance += """ + + ### Built-in Search (preferred when available) + Your responses automatically incorporate live web search results from the model provider. For most queries, answer directly — your reply already includes real-time search data. Do NOT say you cannot search. + Use the `search` tool ONLY when you need precise time filtering (e.g., "yesterday's news" → freshness=day), a specific language, or when built-in results feel insufficient. + """; } // Wiki 知识库上下文注入 diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java index de14bb4f..2a4335a2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java @@ -206,9 +206,25 @@ public class BrowserLauncher { return null; } + // Issue #40 — On Windows (and macOS) Chrome refuses to spawn a second instance against + // the default user-data-dir if the user already has Chrome open: the new chrome.exe just + // forwards its argv to the running instance and exits, so stderr never prints + // "DevTools listening on ...". An isolated profile dir avoids that conflict and is also + // what the openfang launcher does. + Path userDataDir; + try { + userDataDir = Files.createTempDirectory("mateclaw-cdp-profile-"); + } catch (Exception e) { + trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, browserBin.toString(), + System.currentTimeMillis() - t0, + "failed to create isolated user-data-dir: " + e.getMessage())); + return null; + } + List command = new ArrayList<>(); command.add(browserBin.toString()); command.add("--remote-debugging-port=0"); + command.add("--user-data-dir=" + userDataDir.toAbsolutePath()); command.add("--no-first-run"); command.add("--no-default-browser-check"); command.add("--disable-extensions"); @@ -241,6 +257,7 @@ public class BrowserLauncher { try { proc = pb.start(); } catch (Exception e) { + deleteQuietly(userDataDir); trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, browserBin + " --remote-debugging-port", System.currentTimeMillis() - t0, "spawn failed: " + e.getMessage())); return null; @@ -251,6 +268,7 @@ public class BrowserLauncher { wsUrl = readDevToolsUrl(proc, props.getCdpTimeoutSeconds()); } catch (Exception e) { proc.destroyForcibly(); + deleteQuietly(userDataDir); trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, browserBin.toString(), System.currentTimeMillis() - t0, e.getMessage())); return null; @@ -264,17 +282,40 @@ public class BrowserLauncher { ? browser.newContext() : browser.contexts().get(0); Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0); + // The temp profile dir is owned by the spawned Chrome for the lifetime of the + // browser; clean up when the JVM exits since we don't track per-session shutdown + // here. (BrowserUseTool stops the browser explicitly on stop, so the JVM hook is + // a backstop for crashes.) + registerProfileDirCleanup(userDataDir); long elapsed = System.currentTimeMillis() - t0; trace.add(Attempt.ok(Strategy.EXTERNAL_CDP, browserBin + " + connectOverCDP(" + cdpBase + ")", elapsed)); return Result.success(browser, context, page, true, cdpBase, Strategy.EXTERNAL_CDP, trace); } catch (Exception e) { proc.destroyForcibly(); + deleteQuietly(userDataDir); trace.add(Attempt.fail(Strategy.EXTERNAL_CDP, "connectOverCDP(" + cdpBase + ")", System.currentTimeMillis() - t0, e.getMessage())); return null; } } + private static void deleteQuietly(Path dir) { + if (dir == null) return; + try { + if (!Files.exists(dir)) return; + try (var stream = Files.walk(dir)) { + stream.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (Exception ignored) {} + }); + } + } catch (Exception ignored) {} + } + + private static void registerProfileDirCleanup(Path dir) { + Runtime.getRuntime().addShutdownHook(new Thread(() -> deleteQuietly(dir), + "mateclaw-cdp-profile-cleanup")); + } + // ==================== Helpers ==================== private BrowserType.LaunchOptions baseLaunchOptions(boolean headed) { @@ -312,7 +353,7 @@ public class BrowserLauncher { return args; } - /** Platform-specific candidate paths — same list openfang uses. */ + /** Platform-specific candidate paths — same list openfang uses, extended for issue #40. */ public static List systemBrowserCandidates() { List paths = new ArrayList<>(); if (IS_WINDOWS) { @@ -328,6 +369,27 @@ public class BrowserLauncher { if (local != null && !local.isBlank()) { paths.add(Path.of(local, "Google", "Chrome", "Application", "chrome.exe")); paths.add(Path.of(local, "Microsoft", "Edge", "Application", "msedge.exe")); + // Chinese Chromium-based browsers — they ignore Playwright's "chrome" channel + // detection but launch fine via executablePath. They tend to install per-user. + paths.add(Path.of(local, "360ChromeX", "Chrome", "Application", "360ChromeX.exe")); + paths.add(Path.of(local, "360Chrome", "Chrome", "Application", "360chrome.exe")); + paths.add(Path.of(local, "360se6", "Application", "360se.exe")); + paths.add(Path.of(local, "Tencent", "QQBrowser", "QQBrowser.exe")); + paths.add(Path.of(local, "sogouexplorer", "SogouExplorer.exe")); + } + // Issue #40: many users install Chrome/Edge to a non-system drive (D:, E:, ...). + // Scan only mounted drives to avoid spurious 5s "no media" timeouts on empty letters. + for (java.io.File drive : java.io.File.listRoots()) { + String letter = drive.getPath(); + if (letter == null || letter.isBlank()) continue; + if (!drive.exists()) continue; + String upper = letter.toUpperCase(Locale.ROOT); + if (upper.startsWith("C:")) continue; // already covered by ProgramFiles env vars + for (String pfDir : new String[]{"Program Files", "Program Files (x86)"}) { + paths.add(Path.of(letter, pfDir, "Google", "Chrome", "Application", "chrome.exe")); + paths.add(Path.of(letter, pfDir, "Microsoft", "Edge", "Application", "msedge.exe")); + paths.add(Path.of(letter, pfDir, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")); + } } } else if (IS_MAC) { paths.add(Path.of("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java index 87c27d78..51760895 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java @@ -83,8 +83,12 @@ public class BrowserUseTool { Default is headless. Use headed=true with action=start for a visible window. Typical flow: start → open(url) → snapshot → click/type → stop. If start fails, run action=diagnose for a full report of what's missing and how to fix it. - When web_search is unavailable (no Serper/Tavily API key), use this tool to fetch content directly: - e.g. action=open url=https://news.google.com/search?q=... then action=snapshot to read the page. + + SCOPE — use this tool ONLY for tasks that require driving a real browser: + clicking, typing into forms, taking screenshots, executing JS in page context, or + interacting with sites that need a logged-in session. For plain web search or + retrieving public page content, prefer the `search` tool — do not call `browser_use` + as a search alternative. Supported actions: - start: Launch a new browser (tries system Chrome, system Edge, then Playwright bundled). Optional headed=true. @@ -154,6 +158,12 @@ public class BrowserUseTool { /** * 获取或创建共享 Playwright 实例(双重检查锁定)。 * 首次调用约 1-2s(启动 Node.js),后续调用 ~0ms。 + * + *

Issue #40: Playwright.create() spawns a Node.js driver subprocess by extracting + * a bundled binary to a temp directory. On Windows this can fail when the user profile + * path contains non-ASCII characters or when antivirus quarantines the extracted exe. + * We wrap the failure with a message that points the LLM/user at action=diagnose so + * they don't get a bare stack trace. */ private Playwright getOrCreatePlaywright() { Playwright pw = sharedPlaywright; @@ -167,7 +177,17 @@ public class BrowserUseTool { } log.info("[BrowserUse] Creating shared Playwright instance..."); long start = System.currentTimeMillis(); - pw = Playwright.create(); + try { + pw = Playwright.create(); + } catch (Throwable t) { + String os = System.getProperty("os.name", "?"); + log.error("[BrowserUse] Playwright.create() failed on {}: {}", os, t.getMessage(), t); + throw new PlaywrightException( + "Failed to start Playwright driver on " + os + ": " + t.getMessage() + + ". Common causes on Windows: (a) user profile path contains non-ASCII chars," + + " (b) antivirus blocked the extracted driver exe, (c) %TEMP% is on a read-only volume." + + " Run action=diagnose for a full report.", t); + } sharedPlaywright = pw; log.info("[BrowserUse] Playwright instance created in {}ms", System.currentTimeMillis() - start); return pw;