fix: search_images 결과를 모델이 프로즈로 바꿔치기해 사진이 안 나오던 문제

papa 세션 실측(2026-09-03): "눈향나무 사진 찾아줘" → search_images가 ![alt](url)
3개를 정상 반환했는데 gemma4가 "눈향나무 사진들입니다" + 설명만 내고 링크를 통째로
누락. 다음 턴 "사진이 어딧지?"에도 "죄송합니다..." 하며 또 0개.

- appendDroppedSearchImages(reply-content.ts): 최종 답변에 이미지 마크다운이
  하나도 없고 이번 턴 search_images가 성공했으면, 도구 결과의 ![](url)를 뒤에 붙임.
  하나라도 있으면 모델이 시도한 것으로 보고 안 건드림
- personality-context photo 프롬프트: "![alt](url) 줄을 VERBATIM으로 복사, 프로즈로
  대체 금지"로 강화 (코드가 backstop, 프롬프트는 보조 — [[feedback_local_model_needs_code_backstop]])

참고: 눈향나무 같은 특정 수종은 Pexels/Unsplash 커버리지가 나빠 "빨간 배경 분재"가
매칭되기도 함 — 그건 스톡 API 한계로 별개 이슈.

tests +5 (473 통과).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdchNLTHsyYahp2PVQjoym
This commit is contained in:
kim
2026-09-03 16:00:42 +09:00
co-authored by Claude Sonnet 5
parent e441bc22fd
commit 2537b04c7a
4 changed files with 67 additions and 3 deletions
+2 -1
View File
@@ -77,7 +77,7 @@ import { createBrowserDesktopAdvisors, type BrowserDesktopAdvisorCtx } from './b
import type { SkillsManager } from '../skills-manager';
import { selectToolsForTurn, bootAllowedTools, codeAiBlockedTools } from './tool-scope';
import { buildChatSystemPrompt } from './system-prompt';
import { replyLooksEmpty } from './reply-content';
import { replyLooksEmpty, appendDroppedSearchImages } from './reply-content';
import { evaluateSearchBudget } from './search-budget';
import { decideAutoRecover, shouldForceMessagingRetry as decideMessagingRetry, shouldForceEmptyGroundingRetry as decideEmptyGroundingRetry, GROUNDING_TOOL_PATTERN } from './retry-decisions';
import { logToolSkip } from './tool-skip-log';
@@ -2164,6 +2164,7 @@ async function handleChat(
finalText = finalText.split(/\n+/)[0].slice(0, 220).trim();
}
finalText = sanitizeFinalReply(finalText, { preflightReason: preflightReasonForTurn }) || 'Hey! How can I help?';
finalText = appendDroppedSearchImages(finalText, allToolResults);
logToDaily(workspacePath, 'SmallClaw', finalText, sessionId);
if (fileOpV2Active) {
+1 -1
View File
@@ -158,7 +158,7 @@ PDF→PPTX WORKFLOW — MANDATORY. When source is a PDF, ALL steps below are REQ
6. In slide specs, use image_url = EXACT workspace-relative path. Never strip folders, never use image_path.
7. ONLY use figure_* and filtered img-* files. Raw unfiltered img-* files are FORBIDDEN.`,
photo: `PHOTO SEARCH: search_images(query, count?) → searches Pexels + Unsplash and returns embeddable image URLs. Display results as markdown images in your response. Use when the user asks to find, show, or search for photos or images.
photo: `PHOTO SEARCH: search_images(query, count?) → searches Pexels + Unsplash and returns ![alt](url) markdown lines. You MUST copy those ![alt](url) lines VERBATIM into your reply — that is the only way the user sees the photos. Do NOT describe the images in prose instead of pasting the links, and do NOT say "here are the photos" without including them. A one-sentence caption after the images is fine; replacing them with a caption is not. Use when the user asks to find, show, or search for photos or images.
IMAGE EDIT TOOL: image_edit(path, operation, ...params) → 이미지 편집 및 변환.
CRITICAL: 이미지 편집/변환 요청에는 반드시 image_edit 툴을 사용. shell/python_eval로 직접 코드 작성하거나 웹사이트/브라우저 사용 금지.
+26
View File
@@ -21,3 +21,29 @@
export function replyLooksEmpty(text: string | null | undefined): boolean {
return !/[\p{L}\p{N}]/u.test(String(text ?? ''));
}
const IMG_MD = /!\[[^\]]*\]\([^)]+\)/;
const IMG_MD_G = /!\[[^\]]*\]\([^)]+\)/g;
/**
* search_images returns `![alt](url)` markdown for the model to pass through verbatim, but
* gemma-class models routinely rewrite the tool result into prose and drop the links entirely
* (2026-09-03: "눈향나무 사진들입니다" with zero images, then "사진이 어딧지?" → "죄송합니다…"
* still with none). If a search_images call succeeded this turn and the reply carries NO image
* markdown at all, append the images so the user actually sees them.
*
* Fires only when the reply has none — a single `![](…)` means the model tried, so leave it.
*/
export function appendDroppedSearchImages(
finalText: string,
toolResults: ReadonlyArray<{ name?: string; error?: boolean; result?: string }> | undefined,
): string {
if (!toolResults?.length || IMG_MD.test(finalText)) return finalText;
for (let i = toolResults.length - 1; i >= 0; i--) {
const r = toolResults[i];
if (r?.name !== 'search_images' || r.error || !r.result) continue;
const imgs = (r.result.match(IMG_MD_G) || []).join('\n\n');
if (imgs) return `${finalText}\n\n${imgs}`.trim();
}
return finalText;
}
+38 -1
View File
@@ -15,7 +15,7 @@
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { replyLooksEmpty } from '../src/gateway/chat/reply-content';
import { replyLooksEmpty, appendDroppedSearchImages } from '../src/gateway/chat/reply-content';
describe('replyLooksEmpty — 짧지만 정당한 답변', () => {
// Observed live: "1+1은? 숫자만 답해" streamed the token "2", and the length rule
@@ -80,3 +80,40 @@ describe('replyLooksEmpty — 비라틴 문자', () => {
assert.equal(replyLooksEmpty('🤔'), true);
});
});
describe('appendDroppedSearchImages', () => {
const imgTool = (n: number) => ({
name: 'search_images',
error: false,
result: Array.from({ length: n }, (_, i) => `![photo ${i + 1}](https://img.example/${i + 1}.jpg)`).join('\n\n'),
});
test('모델이 이미지를 다 빠뜨렸으면 도구 결과에서 끌어와 붙인다', () => {
// 2026-09-03 실제 사고: "눈향나무 사진들입니다" 인데 링크가 0개, 다음 턴 "죄송합니다..."도 0개
const out = appendDroppedSearchImages('눈향나무 사진들입니다. 옆으로 퍼지며 자랍니다.', [imgTool(3)]);
assert.equal((out.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length, 3);
assert.ok(out.startsWith('눈향나무 사진들입니다'));
});
test('모델이 이미지를 하나라도 넣었으면 건드리지 않는다', () => {
const reply = '사진입니다.\n\n![a](https://img.example/1.jpg)';
assert.equal(appendDroppedSearchImages(reply, [imgTool(3)]), reply);
});
test('search_images 호출이 없으면 그대로', () => {
assert.equal(appendDroppedSearchImages('그냥 답변', [{ name: 'web_search', error: false, result: 'x' }]), '그냥 답변');
assert.equal(appendDroppedSearchImages('그냥 답변', undefined), '그냥 답변');
});
test('search_images가 에러였으면 붙이지 않는다', () => {
assert.equal(
appendDroppedSearchImages('사진을 못 찾았습니다.', [{ name: 'search_images', error: true, result: 'No images found' }]),
'사진을 못 찾았습니다.',
);
});
test('여러 search_images 중 마지막 성공분을 쓴다', () => {
const out = appendDroppedSearchImages('사진입니다', [imgTool(1), { name: 'search_images', error: true, result: 'fail' }, imgTool(2)]);
assert.equal((out.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length, 2);
});
});