4 Commits

Author SHA1 Message Date
javis-bot
ffc16665e5 fix(controlBrowser): never report moveMouse/search success without a real move
Some checks failed
Release / semantic-release (push) Successful in 19s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m15s
Release / build-linux (push) Failing after 7m7s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
moveMouse returned ok:true even when humanHover did nothing (no on-screen box)
or the selector never matched — recreating the "claims it moved but didn't"
bug. Now: humanHover returns a boolean (and brings the element into view first);
moveMouse returns ok:false when the target isn't found or has no on-screen box,
and when site=naver/... whose box isn't on the current page it navigates to the
site home first before hovering. search now reports input=human|api-fallback|api
so a silent fallback to cursor-less DOM input is visible, and the tool surfaces
that note in the reply instead of implying a human-like search happened.
2026-06-24 19:17:46 +09:00
javis-bot
5629da7e9f feat(controlBrowser): add moveMouse (hover) action for the visible cursor
The tool had no cursor-move/hover action, so "move the mouse to the search box"
had nothing to call and a weak model just claimed it had moved it. Add a
moveMouse action wired to human.humanHover (real xdotool cursor), targetable by
CSS selector or site= (that site's search box). Also clarify in the tool
description that 'navigate' types into the address bar (no mouse) while
'search'/'type' move the real cursor to the on-page box and click before
typing, so the model picks the visible-cursor path when the user wants it.
2026-06-24 19:14:04 +09:00
javis-bot
83999a5b0b fix(prompts): classify every sub-8B model (2b/4b/5b/6b) as SMALL
Some checks failed
Release / semantic-release (push) Successful in 24s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m16s
Release / build-linux (push) Failing after 7m21s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
detect_model_size only matched 1b/3b/7b, so a genuinely small model such as
qwen*:4b fell through to LARGE and got the terse, less-guided prompt set it
can't follow — contributing to off-tone, rambling replies. Extend the small
patterns to cover all sub-8B sizes (1b-7b) across :/-/_ separators and sync the
spec table.
2026-06-24 18:31:54 +09:00
javis-bot
d5fd218c86 fix(reply): stop weak models parroting persona example facts
A 4b model replied to "하이" with "테니스 연습을 Trenches Gym에서..." — it copied
the literal "box at Trenches Gym" few-shot example embedded in the persona
prompt and mangled boxing into tennis, presenting a prompt example as if it
were a real user fact. Remove the copyable proper-noun example and add an
explicit guard: use ONLY names/places/activities that literally appear in the
memory section, never borrow them from the instructions or example wording.
2026-06-24 18:31:54 +09:00
9 changed files with 176 additions and 28 deletions

View File

@@ -17,6 +17,7 @@
// status | listTabs // status | listTabs
// navigate {url} | back | forward | refresh // navigate {url} | back | forward | refresh
// newTab {url?} | closeTab {index?} | activateTab {index} | closePopups // newTab {url?} | closeTab {index?} | activateTab {index} | closePopups
// moveMouse {selector | site} (hover the real cursor, no click)
// click {selector} | type {text, selector?} | scroll {dir, notches?} // click {selector} | type {text, selector?} | scroll {dir, notches?}
// pressKey {key} | screenshot {path} // pressKey {key} | screenshot {path}
import { chromium } from 'playwright'; 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}`); 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 // The genuinely-active tab is the one whose document is visible. Playwright has
// no "active page" accessor over CDP, so probe visibilityState (fixes treating // no "active page" accessor over CDP, so probe visibilityState (fixes treating
// tab 0 as active and breaking sequential ops on a specific tab). // tab 0 as active and breaking sequential ops on a specific tab).
@@ -103,13 +113,6 @@ try {
const q = String(cmd.query || '').trim(); const q = String(cmd.query || '').trim();
if (!q) throw new Error('search: no query'); if (!q) throw new Error('search: no query');
const siteKey = String(cmd.site || 'google').toLowerCase(); 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; const s = SITES[siteKey] || SITES.google;
await front(page); await front(page);
// 1) Go to the homepage. // 1) Go to the homepage.
@@ -122,23 +125,31 @@ try {
// 2) Click the on-page search box, type the query, submit. // 2) Click the on-page search box, type the query, submit.
const box = page.locator(s.box).first(); const box = page.locator(s.box).first();
await box.waitFor({ state: 'visible', timeout: 15000 }).catch(() => {}); 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) { if (HAS_XDOTOOL && cmd.human !== false) {
try { try {
await human.humanClick(page, box); await human.humanClick(page, box);
await human.humanType(q); await human.humanType(q);
await human.pressKey('Return'); await human.pressKey('Return');
searchInput = 'human';
} catch { } catch {
searchInput = 'api-fallback';
await box.click().catch(() => {}); await box.click().catch(() => {});
await box.fill(q).catch(() => {}); await box.fill(q).catch(() => {});
await page.keyboard.press('Enter').catch(() => {}); await page.keyboard.press('Enter').catch(() => {});
} }
} else { } else {
searchInput = 'api';
await box.click().catch(() => {}); await box.click().catch(() => {});
await box.fill(q); await box.fill(q);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
} }
await page.waitForLoadState('domcontentloaded').catch(() => {}); 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; break;
} }
@@ -205,6 +216,45 @@ try {
break; 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': { case 'click': {
const selector = String(cmd.selector || '').trim(); const selector = String(cmd.selector || '').trim();
if (!selector) throw new Error('click: no selector'); if (!selector) throw new Error('click: no selector');

View File

@@ -136,14 +136,18 @@ export async function navigateOmnibox(text) {
} }
// Move the real cursor over an element (hover, no click) - e.g. to reveal a // 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) { export async function humanHover(page, locator) {
const box = await locator.boundingBox().catch(() => null); const box = await bringIntoView(page, locator);
if (!box) return; 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 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 bx = Math.max(0, Math.round((g.ow - g.iw) / 2));
const oy = g.sy + Math.max(0, g.oh - g.ih - bx); 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)); 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 }; export { sleep, rand };

View File

@@ -22,11 +22,15 @@ class ModelSize(Enum):
LARGE = "large" # 8b+ - can infer tool usage from context 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 = ( _SMALL_MODEL_PATTERNS = (
":1b", ":3b", ":7b", ":1b", ":2b", ":3b", ":4b", ":5b", ":6b", ":7b",
"-1b", "-3b", "-7b", "-1b", "-2b", "-3b", "-4b", "-5b", "-6b", "-7b",
"_1b", "_3b", "_7b", "_1b", "_2b", "_3b", "_4b", "_5b", "_6b", "_7b",
"gemma4", # Gemma 4 - always small regardless of tag "gemma4", # Gemma 4 - always small regardless of tag
) )

View File

@@ -4,7 +4,7 @@ This module provides model-size-aware prompt generation for the reply engine.
### Problem Statement ### 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) - Requests that require tools (weather, search, data retrieval)
- Simple conversation (greetings, small talk, general knowledge) - 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 | | 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 | | LARGE | All others (8b+) | Proactive — "use tools confidently" + short anti-confabulation + auto-derive clause |
### Architecture ### Architecture

View File

@@ -120,10 +120,13 @@ _SYSTEM_PROMPT_TEMPLATE: str = (
"'tell me a joke', 'chat with me'), never reply with a bare greeting like 'Hey there!', " "'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. " "'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 " "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 " "fact from it and build the reply around that fact, opening with a short natural reference to "
"Gym — how's training going this week?'). Do not talk about things that are not in that " "it. CRITICAL: use ONLY names, places, activities, and details that literally appear in that "
"section. Only when that section is absent may you invent a fresh observation, question, or " "section — never borrow any name, place, or activity from these instructions or from any "
"joke. Produce a varied response each time — do not repeat a previous reply verbatim. " "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', " "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 " "'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 " "personal details outside of our conversation history', 'I do not store personal details "

View File

@@ -45,9 +45,16 @@ class ControlBrowserTool(Tool):
"Use this (NOT webSearch) whenever the user wants something done or shown IN the " "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 " "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, " "'search' with site=naver/google/daum/youtube/bing), go back/forward, refresh, "
"manage tabs (list/new/close/switch), close popups, click, type, scroll, or " "manage tabs (list/new/close/switch), close popups, move the mouse onto an element, "
"screenshot. webSearch only returns text and shows nothing on screen; this tool " "click, type, scroll, or screenshot. webSearch only returns text and shows nothing "
"actually navigates the visible browser. Only available in screen-share mode. " "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." "Never claim you did any of this unless this tool returns success."
) )
@@ -61,16 +68,16 @@ class ControlBrowserTool(Tool):
"enum": [ "enum": [
"status", "listTabs", "navigate", "search", "back", "status", "listTabs", "navigate", "search", "back",
"forward", "refresh", "newTab", "closeTab", "activateTab", "forward", "refresh", "newTab", "closeTab", "activateTab",
"closePopups", "click", "type", "scroll", "pressKey", "closePopups", "moveMouse", "click", "type", "scroll", "pressKey",
"screenshot", "screenshot",
], ],
"description": "What to do in the browser.", "description": "What to do in the browser.",
}, },
"url": {"type": "string", "description": "Target URL/site for navigate/newTab (e.g. 'naver.com')."}, "url": {"type": "string", "description": "Target URL/site for navigate/newTab (e.g. 'naver.com')."},
"query": {"type": "string", "description": "Search text for action 'search'."}, "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)."}, "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."}, "text": {"type": "string", "description": "Text to type."},
"key": {"type": "string", "description": "Key to press, e.g. 'Return', 'Escape'."}, "key": {"type": "string", "description": "Key to press, e.g. 'Return', 'Escape'."},
"dir": {"type": "string", "description": "Scroll direction: 'down' or 'up'."}, "dir": {"type": "string", "description": "Scroll direction: 'down' or 'up'."},
@@ -149,7 +156,12 @@ class ControlBrowserTool(Tool):
if action == "navigate": if action == "navigate":
return f"브라우저에서 {data.get('url', args.get('url'))} 로 이동했습니다." return f"브라우저에서 {data.get('url', args.get('url'))} 로 이동했습니다."
if action == "search": 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"): if action in ("back", "forward", "refresh"):
return f"브라우저: {action} 완료 ({data.get('url', '')})." return f"브라우저: {action} 완료 ({data.get('url', '')})."
if action in ("status", "listTabs"): if action in ("status", "listTabs"):
@@ -164,6 +176,9 @@ class ControlBrowserTool(Tool):
return f"{data.get('active')}번으로 전환했습니다." return f"{data.get('active')}번으로 전환했습니다."
if action == "closePopups": if action == "closePopups":
return f"팝업/빈 탭 {data.get('closed')}개를 닫았습니다." 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": if action == "screenshot":
return f"화면을 캡처했습니다: {data.get('path')}" return f"화면을 캡처했습니다: {data.get('path')}"
return "완료했습니다." return "완료했습니다."

View 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

View File

@@ -21,9 +21,15 @@ class TestModelSizeDetection:
("gemma:7b", True), ("gemma:7b", True),
("phi3:3b", True), ("phi3:3b", True),
("qwen2:7b", 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 # Various separators
("model-3b-instruct", True), ("model-3b-instruct", True),
("model_1b_chat", True), ("model_1b_chat", True),
("model-4b-instruct", True),
# Large models (should return LARGE) # Large models (should return LARGE)
("gpt-oss:20b", False), ("gpt-oss:20b", False),
("llama3.1:8b", False), ("llama3.1:8b", False),

View File

@@ -60,6 +60,16 @@ class TestBuildSystemPrompt:
assert "SINGLE sentence" in prompt assert "SINGLE sentence" in prompt
assert "never add it as " 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: class TestOutputLanguageDirective:
"""A deployment may lock replies to a single language via OUTPUT_LANGUAGE. """A deployment may lock replies to a single language via OUTPUT_LANGUAGE.