Compare commits
4 Commits
23b1fe692b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffc16665e5 | ||
|
|
5629da7e9f | ||
|
|
83999a5b0b | ||
|
|
d5fd218c86 |
@@ -17,6 +17,7 @@
|
||||
// status | listTabs
|
||||
// navigate {url} | back | forward | refresh
|
||||
// newTab {url?} | closeTab {index?} | activateTab {index} | closePopups
|
||||
// moveMouse {selector | site} (hover the real cursor, no click)
|
||||
// click {selector} | type {text, selector?} | scroll {dir, notches?}
|
||||
// pressKey {key} | screenshot {path}
|
||||
import { chromium } from 'playwright';
|
||||
@@ -40,6 +41,15 @@ if (!action) { out({ ok: false, error: 'no action' }); process.exit(1); }
|
||||
|
||||
const norm = (u) => (/^https?:\/\//i.test(u) ? u : `https://${u}`);
|
||||
|
||||
// Per-site homepage + search-box selector, shared by `search` and `moveMouse`.
|
||||
const SITES = {
|
||||
naver: { home: 'https://www.naver.com', box: '#query, input[name="query"]' },
|
||||
google: { home: 'https://www.google.com', box: 'textarea[name="q"], input[name="q"]' },
|
||||
daum: { home: 'https://www.daum.net', box: '#q, input[name="q"]' },
|
||||
youtube: { home: 'https://www.youtube.com', box: 'input#search, input[name="search_query"]' },
|
||||
bing: { home: 'https://www.bing.com', box: '#sb_form_q, input[name="q"]' },
|
||||
};
|
||||
|
||||
// The genuinely-active tab is the one whose document is visible. Playwright has
|
||||
// no "active page" accessor over CDP, so probe visibilityState (fixes treating
|
||||
// tab 0 as active and breaking sequential ops on a specific tab).
|
||||
@@ -103,13 +113,6 @@ try {
|
||||
const q = String(cmd.query || '').trim();
|
||||
if (!q) throw new Error('search: no query');
|
||||
const siteKey = String(cmd.site || 'google').toLowerCase();
|
||||
const SITES = {
|
||||
naver: { home: 'https://www.naver.com', box: '#query, input[name="query"]' },
|
||||
google: { home: 'https://www.google.com', box: 'textarea[name="q"], input[name="q"]' },
|
||||
daum: { home: 'https://www.daum.net', box: '#q, input[name="q"]' },
|
||||
youtube: { home: 'https://www.youtube.com', box: 'input#search, input[name="search_query"]' },
|
||||
bing: { home: 'https://www.bing.com', box: '#sb_form_q, input[name="q"]' },
|
||||
};
|
||||
const s = SITES[siteKey] || SITES.google;
|
||||
await front(page);
|
||||
// 1) Go to the homepage.
|
||||
@@ -122,23 +125,31 @@ try {
|
||||
// 2) Click the on-page search box, type the query, submit.
|
||||
const box = page.locator(s.box).first();
|
||||
await box.waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
|
||||
// Report which input path actually ran: 'human' = real xdotool cursor
|
||||
// move + char typing; 'api-fallback' = the humanClick path threw and we
|
||||
// fell back to cursor-less DOM click/fill; 'api' = no xdotool at all. This
|
||||
// makes "did the cursor really move" verifiable from the result.
|
||||
let searchInput;
|
||||
if (HAS_XDOTOOL && cmd.human !== false) {
|
||||
try {
|
||||
await human.humanClick(page, box);
|
||||
await human.humanType(q);
|
||||
await human.pressKey('Return');
|
||||
searchInput = 'human';
|
||||
} catch {
|
||||
searchInput = 'api-fallback';
|
||||
await box.click().catch(() => {});
|
||||
await box.fill(q).catch(() => {});
|
||||
await page.keyboard.press('Enter').catch(() => {});
|
||||
}
|
||||
} else {
|
||||
searchInput = 'api';
|
||||
await box.click().catch(() => {});
|
||||
await box.fill(q);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
out({ ok: true, site: SITES[siteKey] ? siteKey : 'google', query: q, url: page.url(), title: await page.title().catch(() => '') });
|
||||
out({ ok: true, site: SITES[siteKey] ? siteKey : 'google', query: q, url: page.url(), title: await page.title().catch(() => ''), input: searchInput });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -205,6 +216,45 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'moveMouse': {
|
||||
// Move/hover the REAL cursor onto an element WITHOUT clicking. Target is a
|
||||
// CSS selector, or site=naver/google/... for that site's search box.
|
||||
// Only meaningful with xdotool (the visible cursor); with no xdotool there
|
||||
// is no cursor to move, so report that rather than faking success. Every
|
||||
// failure to actually move (no xdotool, selector never matches, element
|
||||
// has no on-screen box) returns ok:false — we must never claim the cursor
|
||||
// moved when it did not (the exact bug the user reported).
|
||||
const siteKey = String(cmd.site || '').toLowerCase();
|
||||
const selector = String(cmd.selector || '').trim() || (SITES[siteKey] ? SITES[siteKey].box : '');
|
||||
if (!selector) throw new Error('moveMouse: no selector or known site');
|
||||
if (!(HAS_XDOTOOL && cmd.human !== false)) {
|
||||
out({ ok: false, error: 'no xdotool: cannot move the visible cursor on this host' });
|
||||
break;
|
||||
}
|
||||
await front(page);
|
||||
let locator = page.locator(selector).first();
|
||||
let visible = await locator.waitFor({ state: 'visible', timeout: 8000 }).then(() => true).catch(() => false);
|
||||
// A named site whose search box isn't on the current page: go to its home
|
||||
// first (real omnibox), then target the box there.
|
||||
if (!visible && SITES[siteKey]) {
|
||||
try { await human.navigateOmnibox(SITES[siteKey].home); await page.waitForLoadState('domcontentloaded').catch(() => {}); }
|
||||
catch { await page.goto(SITES[siteKey].home, { waitUntil: 'domcontentloaded' }).catch(() => {}); }
|
||||
locator = page.locator(SITES[siteKey].box).first();
|
||||
visible = await locator.waitFor({ state: 'visible', timeout: 8000 }).then(() => true).catch(() => false);
|
||||
}
|
||||
if (!visible) {
|
||||
out({ ok: false, error: `moveMouse: target not found (${cmd.selector || siteKey})` });
|
||||
break;
|
||||
}
|
||||
const moved = await human.humanHover(page, locator);
|
||||
if (!moved) {
|
||||
out({ ok: false, error: 'moveMouse: element has no on-screen box; cursor not moved' });
|
||||
break;
|
||||
}
|
||||
out({ ok: true, target: cmd.selector || siteKey, input: 'human' });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'click': {
|
||||
const selector = String(cmd.selector || '').trim();
|
||||
if (!selector) throw new Error('click: no selector');
|
||||
|
||||
@@ -136,14 +136,18 @@ export async function navigateOmnibox(text) {
|
||||
}
|
||||
|
||||
// Move the real cursor over an element (hover, no click) - e.g. to reveal a
|
||||
// video player's controls or to focus it for a keyboard shortcut.
|
||||
// video player's controls or to focus it for a keyboard shortcut. Returns true
|
||||
// only if the element had an on-screen box and the cursor was actually moved;
|
||||
// returns false when there is nothing to move to (so callers must not report
|
||||
// success). Brings the element into view with a real wheel scroll first.
|
||||
export async function humanHover(page, locator) {
|
||||
const box = await locator.boundingBox().catch(() => null);
|
||||
if (!box) return;
|
||||
const box = await bringIntoView(page, locator);
|
||||
if (!box) return false;
|
||||
const g = await page.evaluate(() => ({ sx: window.screenX, sy: window.screenY, ow: window.outerWidth, oh: window.outerHeight, iw: window.innerWidth, ih: window.innerHeight }));
|
||||
const bx = Math.max(0, Math.round((g.ow - g.iw) / 2));
|
||||
const oy = g.sy + Math.max(0, g.oh - g.ih - bx);
|
||||
await humanMove(Math.round(g.sx + bx + box.x + box.width * 0.5), Math.round(oy + box.y + box.height * 0.4));
|
||||
return true;
|
||||
}
|
||||
|
||||
export { sleep, rand };
|
||||
|
||||
@@ -22,11 +22,15 @@ class ModelSize(Enum):
|
||||
LARGE = "large" # 8b+ - can infer tool usage from context
|
||||
|
||||
|
||||
# Model size patterns - models matching these are considered SMALL
|
||||
# Model size patterns - models matching these are considered SMALL.
|
||||
# Covers every sub-8B size (1b-7b): these models need the explicit, repeated
|
||||
# tool/greeting/instruction constraints and falter on the terse LARGE prompt.
|
||||
# Without 2b/4b/5b/6b here a genuinely small model (e.g. qwen*:4b) was
|
||||
# misclassified as LARGE and given the less-guided prompt set.
|
||||
_SMALL_MODEL_PATTERNS = (
|
||||
":1b", ":3b", ":7b",
|
||||
"-1b", "-3b", "-7b",
|
||||
"_1b", "_3b", "_7b",
|
||||
":1b", ":2b", ":3b", ":4b", ":5b", ":6b", ":7b",
|
||||
"-1b", "-2b", "-3b", "-4b", "-5b", "-6b", "-7b",
|
||||
"_1b", "_2b", "_3b", "_4b", "_5b", "_6b", "_7b",
|
||||
"gemma4", # Gemma 4 - always small regardless of tag
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This module provides model-size-aware prompt generation for the reply engine.
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Small models (1b, 3b, 7b parameters) lack the reasoning capacity to infer when NOT to use tools. When given prompts like "Proactively use available tools," they may incorrectly call tools for simple greetings like "hello" or "ni hao" because they cannot distinguish between:
|
||||
Small models (every sub-8B size, 1b-7b parameters) lack the reasoning capacity to infer when NOT to use tools. When given prompts like "Proactively use available tools," they may incorrectly call tools for simple greetings like "hello" or "ni hao" because they cannot distinguish between:
|
||||
- Requests that require tools (weather, search, data retrieval)
|
||||
- Simple conversation (greetings, small talk, general knowledge)
|
||||
|
||||
@@ -14,7 +14,7 @@ The module detects model size from the model name and selects appropriate prompt
|
||||
|
||||
| Model Size | Detection Pattern | Tool Prompts |
|
||||
|------------|-------------------|--------------|
|
||||
| SMALL | `:1b`, `:3b`, `:7b`, `gemma4` | Conservative — explicit "DO NOT use tools for greetings" + worked negative examples + repetition |
|
||||
| SMALL | `:1b`-`:7b` (every size 1-7B, all separators), `gemma4` | Conservative — explicit "DO NOT use tools for greetings" + worked negative examples + repetition |
|
||||
| LARGE | All others (8b+) | Proactive — "use tools confidently" + short anti-confabulation + auto-derive clause |
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -120,10 +120,13 @@ _SYSTEM_PROMPT_TEMPLATE: str = (
|
||||
"'tell me a joke', 'chat with me'), never reply with a bare greeting like 'Hey there!', "
|
||||
"'Hi!', 'How can I help you?', or a generic observation about an unrelated topic. "
|
||||
"When the 'Information the user has shared…' section is present, you MUST pick one concrete "
|
||||
"fact from it and build the reply around that fact (e.g. 'You mentioned you box at Trenches "
|
||||
"Gym — how's training going this week?'). Do not talk about things that are not in that "
|
||||
"section. Only when that section is absent may you invent a fresh observation, question, or "
|
||||
"joke. Produce a varied response each time — do not repeat a previous reply verbatim. "
|
||||
"fact from it and build the reply around that fact, opening with a short natural reference to "
|
||||
"it. CRITICAL: use ONLY names, places, activities, and details that literally appear in that "
|
||||
"section — never borrow any name, place, or activity from these instructions or from any "
|
||||
"example wording, and never invent specifics that are not in that section. Do not talk about "
|
||||
"things that are not in that section. Only when that section is absent may you invent a fresh "
|
||||
"observation, question, or joke. Produce a varied response each time — do not repeat a "
|
||||
"previous reply verbatim. "
|
||||
"Banned phrasings: 'I can only tell you what you have shared with me in this conversation', "
|
||||
"'I don't have access to any personal information outside of what you tell me', 'I don't have "
|
||||
"personal details outside of our conversation history', 'I do not store personal details "
|
||||
|
||||
@@ -45,9 +45,16 @@ class ControlBrowserTool(Tool):
|
||||
"Use this (NOT webSearch) whenever the user wants something done or shown IN the "
|
||||
"browser on screen: open a website or URL, search on a specific site (action "
|
||||
"'search' with site=naver/google/daum/youtube/bing), go back/forward, refresh, "
|
||||
"manage tabs (list/new/close/switch), close popups, click, type, scroll, or "
|
||||
"screenshot. webSearch only returns text and shows nothing on screen; this tool "
|
||||
"actually navigates the visible browser. Only available in screen-share mode. "
|
||||
"manage tabs (list/new/close/switch), close popups, move the mouse onto an element, "
|
||||
"click, type, scroll, or screenshot. webSearch only returns text and shows nothing "
|
||||
"on screen; this tool actually navigates the visible browser. "
|
||||
"Cursor behaviour matters: 'search' and 'type' (with a selector) move the REAL mouse "
|
||||
"cursor to the on-page box, click it, then type one character at a time — use these "
|
||||
"when the user wants to search or type on a page. 'navigate' only types the URL into "
|
||||
"the address bar (no mouse movement). 'moveMouse' moves/hovers the visible cursor "
|
||||
"onto an element (selector, or site=naver/... for that site's search box) WITHOUT "
|
||||
"clicking — use it when the user just asks to move the mouse somewhere. "
|
||||
"Only available in screen-share mode. "
|
||||
"Never claim you did any of this unless this tool returns success."
|
||||
)
|
||||
|
||||
@@ -61,16 +68,16 @@ class ControlBrowserTool(Tool):
|
||||
"enum": [
|
||||
"status", "listTabs", "navigate", "search", "back",
|
||||
"forward", "refresh", "newTab", "closeTab", "activateTab",
|
||||
"closePopups", "click", "type", "scroll", "pressKey",
|
||||
"closePopups", "moveMouse", "click", "type", "scroll", "pressKey",
|
||||
"screenshot",
|
||||
],
|
||||
"description": "What to do in the browser.",
|
||||
},
|
||||
"url": {"type": "string", "description": "Target URL/site for navigate/newTab (e.g. 'naver.com')."},
|
||||
"query": {"type": "string", "description": "Search text for action 'search'."},
|
||||
"site": {"type": "string", "description": "Search site for action 'search': naver, google, daum, youtube, bing."},
|
||||
"site": {"type": "string", "description": "Site for action 'search' or 'moveMouse': naver, google, daum, youtube, bing. For moveMouse it targets that site's search box."},
|
||||
"index": {"type": "integer", "description": "Tab index for closeTab/activateTab (from listTabs)."},
|
||||
"selector": {"type": "string", "description": "CSS selector for click/type."},
|
||||
"selector": {"type": "string", "description": "CSS selector for click/type/moveMouse."},
|
||||
"text": {"type": "string", "description": "Text to type."},
|
||||
"key": {"type": "string", "description": "Key to press, e.g. 'Return', 'Escape'."},
|
||||
"dir": {"type": "string", "description": "Scroll direction: 'down' or 'up'."},
|
||||
@@ -149,7 +156,12 @@ class ControlBrowserTool(Tool):
|
||||
if action == "navigate":
|
||||
return f"브라우저에서 {data.get('url', args.get('url'))} 로 이동했습니다."
|
||||
if action == "search":
|
||||
return f"{data.get('site', '')}에서 '{data.get('query', args.get('query'))}'를 검색해 화면에 띄웠습니다."
|
||||
base = f"{data.get('site', '')}에서 '{data.get('query', args.get('query'))}'를 검색해 화면에 띄웠습니다."
|
||||
# Flag when the real cursor path didn't run, so a silent fallback to
|
||||
# cursor-less DOM input is visible rather than reported as "human".
|
||||
if data.get("input") in ("api", "api-fallback"):
|
||||
base += " (참고: 실제 마우스 커서 이동 없이 처리됨)"
|
||||
return base
|
||||
if action in ("back", "forward", "refresh"):
|
||||
return f"브라우저: {action} 완료 ({data.get('url', '')})."
|
||||
if action in ("status", "listTabs"):
|
||||
@@ -164,6 +176,9 @@ class ControlBrowserTool(Tool):
|
||||
return f"탭 {data.get('active')}번으로 전환했습니다."
|
||||
if action == "closePopups":
|
||||
return f"팝업/빈 탭 {data.get('closed')}개를 닫았습니다."
|
||||
if action == "moveMouse":
|
||||
target = data.get("target") or args.get("selector") or args.get("site") or "대상"
|
||||
return f"마우스 커서를 {target} 위치로 옮겼습니다."
|
||||
if action == "screenshot":
|
||||
return f"화면을 캡처했습니다: {data.get('path')}"
|
||||
return "완료했습니다."
|
||||
|
||||
56
tests/test_control_browser.py
Normal file
56
tests/test_control_browser.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Tests for the controlBrowser tool's action surface.
|
||||
|
||||
These are deterministic schema/summary checks — they do not drive a real
|
||||
browser. The actual cursor movement is exercised live on the browser host
|
||||
(xdotool + CDP), which these tests cannot reach.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from jarvis.tools.builtin.control_browser import ControlBrowserTool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool():
|
||||
return ControlBrowserTool()
|
||||
|
||||
|
||||
def test_movemouse_is_an_exposed_action(tool):
|
||||
# A weak model confabulated "moved the mouse" because no move/hover action
|
||||
# existed to call. The cursor-move capability must be a real action so the
|
||||
# request "move the mouse to the search box" maps to a tool call.
|
||||
enum = tool.inputSchema["properties"]["action"]["enum"]
|
||||
assert "moveMouse" in enum
|
||||
|
||||
|
||||
def test_movemouse_summary_reports_the_target(tool):
|
||||
summary = tool._summarise("moveMouse", {"site": "naver"}, {"ok": True, "target": "naver"})
|
||||
assert "마우스" in summary and "naver" in summary
|
||||
|
||||
|
||||
def test_description_distinguishes_cursor_paths(tool):
|
||||
# The model must know navigate is address-bar only (no mouse) while
|
||||
# search/type/moveMouse move the real cursor — that distinction is the
|
||||
# whole point of the fix.
|
||||
desc = tool.description
|
||||
assert "moveMouse" in desc
|
||||
assert "address bar" in desc # navigate is described as address-bar typing
|
||||
|
||||
|
||||
def test_search_summary_flags_cursorless_fallback(tool):
|
||||
# When the real xdotool cursor path didn't run, the summary must say so
|
||||
# rather than implying a human-like search happened.
|
||||
human = tool._summarise("search", {"query": "날씨"}, {"ok": True, "site": "naver", "query": "날씨", "input": "human"})
|
||||
assert "참고: 실제 마우스" not in human
|
||||
|
||||
fell_back = tool._summarise("search", {"query": "날씨"}, {"ok": True, "site": "naver", "query": "날씨", "input": "api-fallback"})
|
||||
assert "실제 마우스 커서 이동 없이" in fell_back
|
||||
|
||||
|
||||
def test_movemouse_summary_only_runs_on_success(tool):
|
||||
# _summarise is only called on ok:true; an ok:false (target not found / no
|
||||
# xdotool) is handled by run() as a failure reply, so a failed move can no
|
||||
# longer be reported as "moved". Sanity-check the success summary names a
|
||||
# target rather than a placeholder when one is present.
|
||||
summary = tool._summarise("moveMouse", {"selector": "#query"}, {"ok": True, "target": "#query"})
|
||||
assert "#query" in summary
|
||||
@@ -21,9 +21,15 @@ class TestModelSizeDetection:
|
||||
("gemma:7b", True),
|
||||
("phi3:3b", True),
|
||||
("qwen2:7b", True),
|
||||
# Sub-8B sizes that were previously misclassified as LARGE.
|
||||
("qwen3.5:4b", True), # the deployed model that produced weak, off-tone replies
|
||||
("gemma2:2b", True),
|
||||
("model:5b", True),
|
||||
("model:6b", True),
|
||||
# Various separators
|
||||
("model-3b-instruct", True),
|
||||
("model_1b_chat", True),
|
||||
("model-4b-instruct", True),
|
||||
# Large models (should return LARGE)
|
||||
("gpt-oss:20b", False),
|
||||
("llama3.1:8b", False),
|
||||
|
||||
@@ -60,6 +60,16 @@ class TestBuildSystemPrompt:
|
||||
assert "SINGLE sentence" in prompt
|
||||
assert "never add it as " in prompt
|
||||
|
||||
def test_persona_has_no_copyable_proper_noun_examples(self):
|
||||
# A weak model parroted the literal "Trenches Gym" example from the
|
||||
# persona as if it were a real user fact (boxing mangled to tennis).
|
||||
# The persona must not embed copyable personal proper nouns, and must
|
||||
# tell the model to use ONLY facts that literally appear in the memory
|
||||
# section — never borrow names/places from the instructions themselves.
|
||||
prompt = build_system_prompt("Jarvis")
|
||||
assert "Trenches" not in prompt
|
||||
assert "never borrow any name, place, or activity from these instructions" in prompt
|
||||
|
||||
|
||||
class TestOutputLanguageDirective:
|
||||
"""A deployment may lock replies to a single language via OUTPUT_LANGUAGE.
|
||||
|
||||
Reference in New Issue
Block a user