diff --git a/src/gateway/chat/system-prompt.ts b/src/gateway/chat/system-prompt.ts index 02de48d..ad47852 100644 --- a/src/gateway/chat/system-prompt.ts +++ b/src/gateway/chat/system-prompt.ts @@ -164,5 +164,5 @@ export function buildChatSystemPrompt(input: SystemPromptInput): string { return isTranslateSession ? `You are a medical translator. Translate the given text into natural Korean, preserving paragraph structure and markdown formatting (##, ###, **bold**, bullet lists). Output ONLY the translation โ€” no commentary, no tool calls, no explanations.` : isProjSession ? `You are a project file designer. Output ONLY the project-files JSON block as instructed. No tool calls. No extra text.` : `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw, a local AI assistant. Do not append the ๐Ÿฆž emoji (or any emoji) to the end of your responses out of habit โ€” only use emoji when it genuinely fits the content.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine โ€” browser/desktop automation requests are pre-authorized.\nKeep CONVERSATIONAL and TASK-EXECUTION replies SHORT (2-4 sentences). Don't think out loud. Act and report. Greet naturally without tools. Two carve-outs to that brevity rule: (1) it does NOT apply to informational answers you researched with a tool (news, weather, factual explanations, comparisons) โ€” those follow the OUTPUT FORMAT rules below, which deliberately ask for fuller sentences; do not compress them back down. (2) Stating uncertainty NEVER counts against the length. If a search came back empty, or the specific figure you were asked for simply isn't in the results, always spend the extra sentence to say so plainly ("๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—๋Š” ์ด ์กฐํ•ฉ์˜ ์‹ค์ธก์น˜๊ฐ€ ์—†์–ด์„œ ๋‹จ์ •ํ•˜๊ธฐ ์–ด๋ ต์Šต๋‹ˆ๋‹ค") instead of compressing it into a confident-sounding one-liner. Brevity pressure must never be the reason you state something as fact โ€” a slightly longer honest answer beats a short wrong one every time. ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned โ€” never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know.${verifyFactsRule} TEMPORAL CONSISTENCY: The current date and day-of-week is given above ("Current date: ${dateStr}") โ€” this is ground truth, more reliable than any search snippet's phrasing. Before answering whether something is open/trading/in-session RIGHT NOW (stock markets, exchanges, offices, stores), first check today's day-of-week against real-world facts (e.g. NYSE and KOSPI do not trade on Saturdays, Sundays, or market holidays, regardless of what time it is) โ€” a market-hours formula alone is not enough if today isn't even a trading day. A search result reporting a "closing price" or "today's headline" is not proof today is a trading/business day โ€” cross-check it against the actual current date above, and if they conflict (e.g. a stale cached result, or a result that doesn't state its own date), trust the current date and say so explicitly rather than presenting the search result as if it were live. This applies just as much to "is it raining/snowing right now" or any other current-state question: a search engine can hand you a page it cached long ago. Before treating a search result as live, check whether it carries its own timestamp โ€” a date in the URL (e.g. "tm=2024.12.13.20:00", "?date=..."), a dateline, or "as of ..." phrasing โ€” and compare it to the current date above. If that timestamp is more than a day or two old, it is NOT "์ง€๊ธˆ"/"ํ˜„์žฌ": say plainly that you couldn't find live data and name the stale date you found instead of presenting old numbers as current. When a result is a data TABLE (observation/measurement tables are the common case), read every row by its own row label, not by position โ€” misaligning one city's or one row's figures onto the next is worse than finding nothing, because it looks authoritative while being wrong. If you're not confident you're reading the right column for the right label, say so rather than guessing.${imageEditRuleBlock}${mediaGenRuleBlock}${browserRuleBlock}${chemistryRuleBlock}${codeOutputRuleBlock}${packageInstallRuleBlock} -OUTPUT FORMAT: When presenting 3+ items (emails, search results, lists), always use a markdown table or structured bullet list with clear headers. Never dump them as a long paragraph. For news specifically: use a table with columns ์ œ๋ชฉ|์š”์•ฝ|์ถœ์ฒ˜, but write each ์š”์•ฝ as 2-3 full sentences covering the article's actual content โ€” not a headline fragment restated. Include EVERY distinct article the news tool returned (it returns up to 10 per call and they are already filtered to the last 48 hours) โ€” do not cherry-pick 3 of them into a "highlights" table. If several calls returned overlapping stories, merge duplicates but keep the union, aiming for a 8-10 row digest whenever that many distinct articles came back. For weather: do NOT force a table โ€” explain conditions in natural, fuller sentences rather than bare numbers, and use everything the tool actually returned: the multi-day trend it gives you (is it warming, cooling, steady?), precipitation chance, and anything notable. ${weatherRoutingBlock}Never pad a weather answer with figures no tool gave you.${modelProfileBlock}${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsCtx}`; +OUTPUT FORMAT: When presenting 3+ items (emails, search results, lists), always use a markdown table or structured bullet list with clear headers. Never dump them as a long paragraph. For news specifically: use a table with columns ์ œ๋ชฉ|์š”์•ฝ|์ถœ์ฒ˜, but write each ์š”์•ฝ as 2-3 full sentences covering the article's actual content โ€” not a headline fragment restated. Include EVERY distinct article the news tool returned (it returns up to 10 per call and they are already filtered to the last 48 hours) โ€” do not cherry-pick 3 of them into a "highlights" table. If several calls returned overlapping stories, merge duplicates but keep the union, aiming for a 8-10 row digest whenever that many distinct articles came back. For weather: do NOT force a table โ€” explain conditions in natural, fuller sentences rather than bare numbers, and use everything the tool actually returned: the multi-day trend it gives you (is it warming, cooling, steady?), precipitation chance, and anything notable. When comparing several cities (e.g. "์˜ค๋Š˜ ์œ ๋Ÿฝ ๋Œ€๋„์‹œ ์ตœ๊ณ  ๊ธฐ์˜จ"), a table is fine for the numbers themselves, but always follow it with a few sentences of actual commentary โ€” which city is hottest/coldest and by how much, any city with a notably different trend (rain vs. dry, warming vs. cooling) than the rest, anything the tool flagged as unusual. A bare two-column table with no discussion wastes data the tool already returned. ${weatherRoutingBlock}Never pad a weather answer with figures no tool gave you.${modelProfileBlock}${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsCtx}`; } diff --git a/src/tools/weather.ts b/src/tools/weather.ts index feff7ec..73f79d0 100644 --- a/src/tools/weather.ts +++ b/src/tools/weather.ts @@ -685,18 +685,7 @@ export const weatherOpenMeteoTool = { // not maxima at all. Route them before the request instead. const { hourly: hourlyVars, daily: dailyVars } = splitOpenMeteoVariables(vars); - try { - let lat: number, lon: number, displayName: string; - const coords = parseCoords(location); - if (coords) { - [lat, lon] = coords; - displayName = location; - } else { - const geo = await geocodeCity(location); - if (!geo) return await locationNotFoundError(location, 'weather_openmeteo'); - ({ lat, lon, displayName } = geo); - } - + const fetchOneCity = async (lat: number, lon: number, displayName: string) => { const params = new URLSearchParams({ latitude: String(lat), longitude: String(lon), @@ -717,7 +706,6 @@ export const weatherOpenMeteoTool = { const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: AbortSignal.timeout(15_000) }); if (!res.ok) throw new Error(`Open-Meteo API error ${res.status}`); const data: any = await res.json(); - const text = formatOpenMeteoReport({ displayName, timezone: data.timezone || 'auto', @@ -735,7 +723,47 @@ export const weatherOpenMeteoTool = { return String(v); }, }); - return { success: true, stdout: text, data: { location: displayName, lat, lon, raw: data } }; + return { text, lat, lon, raw: data }; + }; + + try { + const coords = parseCoords(location); + if (coords) { + const [lat, lon] = coords; + const { text, raw } = await fetchOneCity(lat, lon, location); + return { success: true, stdout: text, data: { location, lat, lon, raw } }; + } + + const geo = await geocodeCity(location); + if (geo) { + const { text, raw } = await fetchOneCity(geo.lat, geo.lon, geo.displayName); + return { success: true, stdout: text, data: { location: geo.displayName, lat: geo.lat, lon: geo.lon, raw } }; + } + + // 2026-08-10: the error path below already tells the model exactly how to retry + // ("call once per city"), but a real incident showed that guidance only gets partially + // followed โ€” asked for 5 cities, it retried 2 and silently dropped 3 (no fabrication, + // just incomplete: it said "let me know if you need the others" instead of finishing the + // list it had already committed to). Rather than trust N follow-up calls to all happen, + // fetch every city here and return one combined report โ€” there is no follow-up to skip. + const cities = await looksLikeMultipleLocations(location); + if (!cities) return await locationNotFoundError(location, 'weather_openmeteo'); + + const settled = await Promise.allSettled(cities.map(async (c) => { + const g = await geocodeCity(c); + if (!g) throw new Error(`${c}: ์œ„์น˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ`); + const { text } = await fetchOneCity(g.lat, g.lon, g.displayName); + return { city: c, text }; + })); + const ok = settled.filter((r): r is PromiseFulfilledResult<{ city: string; text: string }> => r.status === 'fulfilled').map(r => r.value); + const failed = settled + .map((r, i) => (r.status === 'rejected' ? cities[i] : null)) + .filter((c): c is string => c !== null); + if (!ok.length) return { success: false, error: `${cities.length}๊ฐœ ๋„์‹œ ์ „๋ถ€ ์กฐํšŒ ์‹คํŒจ: ${cities.join(', ')}` }; + + const combined = ok.map(r => r.text).join('\n\n' + 'โ”€'.repeat(40) + '\n\n') + + (failed.length ? `\n\n(๋‹ค์Œ ๋„์‹œ๋Š” ์กฐํšŒ ์‹คํŒจ: ${failed.join(', ')})` : ''); + return { success: true, stdout: combined, data: { locations: ok.map(r => r.city), failed } }; } catch (err: any) { return { success: false, error: err.message }; } diff --git a/tests/weather-multi-location.test.ts b/tests/weather-multi-location.test.ts index a7071d3..d7175e6 100644 --- a/tests/weather-multi-location.test.ts +++ b/tests/weather-multi-location.test.ts @@ -1,16 +1,24 @@ /** * weather-multi-location.test.ts * - * What a weather tool says when `location` holds several cities instead of one. + * What weather_openmeteo does when `location` holds several cities instead of one. * * Asked to compare cities, models batch them into a single argument - * ("London, Berlin, Paris, Rome, Madrid"). That geocodes to nothing, and the old error โ€” - * a bare "์œ„์น˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" โ€” gave the model no reason to do anything but reissue the - * identical call, so the turn looped until it ran out of rounds (observed live 2026-08-09 09:09). + * ("London, Berlin, Paris, Rome, Madrid"). That geocodes to nothing. * - * The property under test is not "does it fail" but "does the failure tell the model how to - * recover", plus its mirror: inputs that are legitimately one place must not be mistaken for - * a list. These tests hit the real Open-Meteo geocoding API, so they are skipped when it is + * First fix (2026-08-09): a bare "์œ„์น˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค" gave the model no reason to do + * anything but reissue the identical call, so the turn looped until it ran out of rounds + * (observed live 09:09). Rewrote the error to name every city and tell the model to call once + * per city instead. + * + * That undershot too (2026-08-10, real incident): asked for 5 cities, the model retried only 2 + * after the improved error and silently dropped the other 3 โ€” no fabrication, just an + * incomplete answer ("๋‹ค๋ฅธ ๋„์‹œ๊ฐ€ ๋” ํ•„์š”ํ•˜์‹œ๋ฉด ๋ง์”€ํ•ด ์ฃผ์„ธ์š”" instead of finishing the list it + * had already committed to). Prose telling the model what to do kept being followed partially, + * not completely, so weather_openmeteo now fetches every city itself on a detected batch and + * returns one combined report โ€” there's no follow-up call left for the model to skip. + * + * These tests hit the real Open-Meteo geocoding/forecast API, so they are skipped when it is * unreachable rather than failing the suite on a network blip. */ @@ -22,6 +30,8 @@ const ask = (location: string) => weatherOpenMeteoTool.execute({ location, variables: 'temperature_2m_max' }) as Promise<{ success: boolean; error?: string; + stdout?: string; + data?: { locations?: string[]; failed?: string[] }; }>; let online = false; @@ -37,23 +47,33 @@ before(async () => { if (!online) console.log(' (geocoding API ์ ‘์† ๋ถˆ๊ฐ€ โ€” ๋„คํŠธ์›Œํฌ ์˜์กด ํ…Œ์ŠคํŠธ ๊ฑด๋„ˆ๋œ€)'); }); -describe('๋ฐฐ์น˜ ์ž…๋ ฅ์€ ๋ณต๊ตฌ ๋ฐฉ๋ฒ•์„ ์•Œ๋ ค์ค€๋‹ค', () => { - test('์—ฌ๋Ÿฌ ๋„์‹œ๋ฅผ ํ•œ ๋ฒˆ์— ๋„˜๊ธฐ๋ฉด ๋„๊ตฌ๋ณ„ ํ˜ธ์ถœ์„ ์ง€์‹œํ•œ๋‹ค', async (t) => { +describe('๋ฐฐ์น˜ ์ž…๋ ฅ์€ ๋„๊ตฌ๊ฐ€ ์ง์ ‘ ์ „๋ถ€ ์กฐํšŒํ•ด ํ•˜๋‚˜๋กœ ํ•ฉ์นœ๋‹ค', () => { + test('์—ฌ๋Ÿฌ ๋„์‹œ๋ฅผ ํ•œ ๋ฒˆ์— ๋„˜๊ธฐ๋ฉด ์ „๋ถ€ ์กฐํšŒํ•ด์„œ ํ•˜๋‚˜์˜ ๊ฒฐ๊ณผ๋กœ ํ•ฉ์นœ๋‹ค', async (t) => { if (!online) return t.skip('offline'); const r = await ask('London, Berlin, Paris, Rome, Madrid'); - assert.equal(r.success, false); - const err = r.error ?? ''; - // ๋ฌด์—‡์ด ์ž˜๋ชป๋๋Š”์ง€ + ์–ด๋–ป๊ฒŒ ๊ณ ์น˜๋Š”์ง€ + ์žฌ์‹œ๋„๊ฐ€ ๋ฌด์˜๋ฏธํ•˜๋‹ค๋Š” ๊ฒƒ, ์„ธ ๊ฐ€์ง€๊ฐ€ ๋‹ค ์žˆ์–ด์•ผ - // ๋ชจ๋ธ์ด ๊ฐ™์€ ํ˜ธ์ถœ์„ ๋ฐ˜๋ณตํ•˜์ง€ ์•Š๋Š”๋‹ค. - assert.match(err, /ํ•œ ๋ฒˆ์— ํ•œ ๊ณณ๋งŒ/, '๋‹จ์ผ ์œ„์น˜ ์ œ์•ฝ์„ ๋ฐํ˜€์•ผ ํ•จ'); - assert.match(err, /weather_openmeteo/, '์–ด๋–ค ๋„๊ตฌ๋ฅผ ๋‹ค์‹œ ๋ถ€๋ฅผ์ง€ ์•Œ๋ ค์•ผ ํ•จ'); - assert.match(err, /"location":"London"/, '๊ตฌ์ฒด์ ์ธ ์žฌํ˜ธ์ถœ ์˜ˆ์‹œ๊ฐ€ ์žˆ์–ด์•ผ ํ•จ'); - assert.match(err, /๋‹ค์‹œ ํ˜ธ์ถœํ•˜๋ฉด ๋˜‘๊ฐ™์ด ์‹คํŒจ/, '๋™์ผ ์žฌ์‹œ๋„๊ฐ€ ๋ฌด์˜๋ฏธํ•จ์„ ๋ฐํ˜€์•ผ ํ•จ'); - // ํŒŒ์‹ฑํ•œ ๋„์‹œ๋ฅผ ์ „๋ถ€ ๋˜๋Œ๋ ค์ค˜์•ผ ๋ชจ๋ธ์ด ๋‚จ์€ ๋„์‹œ๋ฅผ ์•ˆ ๋น ๋œจ๋ฆฐ๋‹ค. + // 2026-08-10 ์ด์ „์—” ์—ฌ๊ธฐ์„œ success=false + ๋„์‹œ๋ณ„๋กœ ๋‹ค์‹œ ๋ถ€๋ฅด๋ผ๋Š” ์•ˆ๋‚ด์˜€๋‹ค. ๊ทธ ์•ˆ๋‚ด๋ฅผ + // ๋ชจ๋ธ์ด ๋ถ€๋ถ„์ ์œผ๋กœ๋งŒ ๋”ฐ๋ผ์„œ(5๊ฐœ ์ค‘ 2๊ฐœ๋งŒ ์žฌ์‹œ๋„) ๋‚˜์˜จ ์‹ค์ œ ์‚ฌ๊ณ  ์ดํ›„, ๋„๊ตฌ๊ฐ€ ์ง์ ‘ ์ „๋ถ€ + // ์กฐํšŒํ•˜๋„๋ก ๋ฐ”๊ฟจ๋‹ค โ€” ๋ชจ๋ธ์ด ๋น ๋œจ๋ฆด ์žฌ์‹œ๋„ ์ž์ฒด๊ฐ€ ์—†์–ด์•ผ ํ•œ๋‹ค. + assert.equal(r.success, true); + const stdout = r.stdout ?? ''; for (const city of ['London', 'Berlin', 'Paris', 'Rome', 'Madrid']) { - assert.ok(err.includes(city), `${city}๊ฐ€ ๋ชฉ๋ก์— ์—†์Œ`); + assert.ok(r.data?.locations?.some(l => l.includes(city)) || stdout.includes(city), `${city} ๊ฒฐ๊ณผ๊ฐ€ ๋น ์ง`); } + // 5๊ฐœ ๋„์‹œ ๊ฒฐ๊ณผ๊ฐ€ ๊ตฌ๋ถ„ ์—†์ด ๋’ค์„ž์ด๋ฉด ์•ˆ ๋˜๋ฏ€๋กœ ๊ตฌ๋ถ„์„ ์ด ์žˆ์–ด์•ผ ํ•จ. + assert.ok(stdout.includes('โ”€'), '๋„์‹œ๋ณ„ ๊ตฌ๋ถ„์„ ์ด ์—†์Œ'); + }); + + test('์ผ๋ถ€ ๋„์‹œ๋งŒ ์‹คํŒจํ•ด๋„ ๋‚˜๋จธ์ง€๋Š” ์ •์ƒ ๋ฐ˜ํ™˜๋œ๋‹ค', async (t) => { + if (!online) return t.skip('offline'); + // looksLikeMultipleLocations๋Š” ๋ฐฐ์น˜ ํŒ๋ณ„์„ ์œ„ํ•ด ์ฒซ ๋‘ ์„ธ๊ทธ๋จผํŠธ๊ฐ€ ๊ฐ๊ฐ ์ง€์˜ค์ฝ”๋”ฉ๋˜๋Š”์ง€๋งŒ + // ๋ณธ๋‹ค(๊ทธ๋ž˜์•ผ "Springfield, Freedonia" ๊ฐ™์€ ๋‹จ์ผ ์ง€๋ช…+๋ฏธํ™•์ธ ์ˆ˜์‹์–ด๋ฅผ ๋ชฉ๋ก์œผ๋กœ ์˜ค์ธํ•˜์ง€ + // ์•Š์Œ) โ€” ๊ทธ๋ž˜์„œ ์‹คํŒจํ•  ๋„์‹œ๋Š” ์„ธ ๋ฒˆ์งธ ์ž๋ฆฌ์— ๋‘”๋‹ค. + const r = await ask('London, Paris, Zzxqwv'); + assert.equal(r.success, true, '์ผ๋ถ€ ์‹คํŒจ๋กœ ์ „์ฒด๊ฐ€ ์‹คํŒจํ•˜๋ฉด ์•ˆ ๋จ'); + assert.ok(r.data?.failed?.some(f => f.includes('Zzxqwv')), '์‹คํŒจํ•œ ๋„์‹œ๊ฐ€ failed ๋ชฉ๋ก์— ์žˆ์–ด์•ผ ํ•จ'); + assert.ok(r.data?.locations?.some(l => l.includes('London'))); + assert.ok(r.data?.locations?.some(l => l.includes('Paris'))); }); });