From c7b5ecb8da469077f28fbfd3085c453f4b6868bf Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Wed, 9 Sep 2026 11:48:00 -0400 Subject: [PATCH] mock: give the chat assistant a tool-calling agent loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LLM now orchestrates the plan instead of only answering from a static snapshot. It reads the live plan, calls deterministic client-side tools, and proposes small fix-ops; the tools validate, compute and commit, so every edit is undo-able (LLM proposes, tools decide). This llama.cpp build ignores the native OpenAI `tools` field, so tool calls use a text protocol: the model emits {"name","args"}, the app executes it and feeds the JSON result back as the next turn; the loop ends when the model stops calling (capped at 10 turns). Tools: itinerary_summary, search_places, place_facts, route_between (real OSRM walk, straight-line fallback), review_plan (day overrun / meal-hour / duplicate checks), and fix-ops add_stop, remove_stop, move_stop, set_duration, set_start. Places and stops resolve by id or name; dayIds are surfaced in the plan snapshot ([D1 · date]) so the model doesn't guess. A subtle ⚙ activity line shows each tool call in the chat. Verified end-to-end in headless Chrome: factual routing (route_between), search+swap (search_places→add_stop, incumbent demoted to backup), and diagnose+fix (review_plan→move_stop→set_start→re-review), with clean undo-able commits each time. --- mock/app.js | 260 ++++++++++++++++++++++++++++++++++++++++++++---- mock/styles.css | 2 + 2 files changed, 242 insertions(+), 20 deletions(-) diff --git a/mock/app.js b/mock/app.js index 1dbfffb..38a4a7a 100644 --- a/mock/app.js +++ b/mock/app.js @@ -60,30 +60,250 @@ function llmContext() { layout(dd); const stops = dd.stops.filter(s => s.state === 'planned').map( s => `${fmt(s.start)} ${s.name} (${s.dur} min${s.price ? ', ' + s.price.amount + s.price.cur : ''})`); - L.push(`${dd.label} [${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`); + L.push(`${dd.label} [${dd.id} · ${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`); } return L.join('\n'); } -function askLLM(v) { - const t = typing(); +// ---------------- LLM agent: tools + calling loop ---------------- +// The model is the orchestrator: it reads the plan, calls deterministic +// tools (search / route / review), and proposes small fix-ops (add / remove / +// move / re-time). The tools decide — they validate, compute, and commit, so +// every edit is undo-able. This llama.cpp build does not honor the native +// OpenAI `tools` field, so tool calls use a text protocol: the model emits +// {"name":...,"args":{...}}, we execute it, and feed the JSON +// result back as the next turn; the loop ends when the model stops calling. +const LLM_TOOL_MAX = 10; // max tool-call turns per user message + +// Live-state lookups (the source of truth is `days`, not the original M.days) +function liveStopById(id) { + for (const d of Object.values(days)) { const s = d.stops.find(x => x.id === id); if (s) return s; } + return null; +} +function candById(id) { + for (const cat of Object.keys(M.candidates)) { + const c = M.candidates[cat].find(x => x.id === id); + if (c) return { ...c, cat }; + } + return null; +} +// Resolve a model-given place ref — "hotel", a stop/candidate id, or a name +// (exact then substring) — to {name, at, id} +function resolvePlace(ref) { + const q = String(ref || '').trim(); const ql = q.toLowerCase(); + if (!q) return null; + if (ql === 'hotel' || ql === 'base' || ql === 'the hotel' || ql === 'my hotel') { + const b = dayBase(); return { name: b.name, at: b.at, id: b.id || 'hotel' }; + } + const s = liveStopById(q); if (s) return { name: s.name, at: s.at, id: s.id }; + const c = candById(q); if (c) return { name: c.name, at: c.at, id: c.id }; + const all = []; + Object.values(days).forEach(d => d.stops.forEach(x => all.push(x))); + Object.values(M.candidates).forEach(pool => pool.forEach(x => all.push(x))); + const hit = all.find(x => x.name.toLowerCase() === ql) || all.find(x => x.name.toLowerCase().includes(ql)); + return hit ? { name: hit.name, at: hit.at, id: hit.id } : null; +} +// Resolve a live stop by id or name (the model often names stops, not ids) +function resolveStop(ref) { + const s = liveStopById(ref); if (s) return s; + const q = String(ref || '').trim().toLowerCase(); + if (!q) return null; + for (const d of Object.values(days)) { + const hit = d.stops.find(x => x.name.toLowerCase() === q) || d.stops.find(x => x.name.toLowerCase().includes(q)); + if (hit) return hit; + } + return null; +} + +const TOOLS = { + // ---- queries (read-only) ---- + itinerary_summary: () => ({ plan: llmContext() }), + + search_places: (a) => { + const cat = String(a.category || '').toLowerCase(); + if (!M.candidates[cat]) return { error: `unknown category "${a.category}"; use ${Object.keys(M.candidates).join(', ')}` }; + let region = null; + if (a.region) { + region = REGION_MAP[String(a.region).toLowerCase()]; + if (!region) return { error: `unknown region "${a.region}"; use ${Object.keys(REGION_MAP).join(', ')} or omit` }; + } + const results = poolFor(cat, region).map(c => ({ + id: c.id, name: c.name, price_eur: c.price, dur_min: c.dur, tags: c.tags, + pitch: c.pitch, walk_from_hotel_min: hotelWalkMin(c.at), + })); + return { category: cat, region: a.region || null, count: results.length, results }; + }, + + place_facts: (a) => { + const p = resolvePlace(a.id || a.name); + if (!p) return { error: `no place matching "${a.id || a.name}"` }; + const c = candById(p.id) || liveStopById(p.id); + return { name: p.name, price_eur: c?.price, dur_min: c?.dur, tags: c?.tags, pitch: c?.pitch, + walk_from_hotel_min: hotelWalkMin(p.at), url: c?.url || null, summary: c?.detail?.summary || null }; + }, + + route_between: async (a) => { + const A = resolvePlace(a.from), B = resolvePlace(a.to); + if (!A) return { error: `cannot resolve "from" = "${a.from}"` }; + if (!B) return { error: `cannot resolve "to" = "${a.to}"` }; + if (A.id === B.id || A.name === B.name) return { from: A.name, to: B.name, minutes: 0, km: 0, source: 'same place' }; + let minutes, km, source; + const res = await routePts([A.at, B.at], 'foot'); + if (res) { minutes = res.durMin; km = Math.round(res.distance / 100) / 10; source = 'local router (OSRM walk)'; } + else { minutes = walkMin(A.at, B.at); km = Math.round(haversine(A.at, B.at) / 100) / 10; + source = routerOn ? 'straight-line est (outside router coverage)' : 'straight-line est (router offline)'; } + return { from: A.name, to: B.name, minutes, km, source }; + }, + + review_plan: (a) => { + const ids = a.dayId ? [a.dayId] : Object.keys(days); + const issues = []; + for (const id of ids) { + const d = days[id]; if (!d) { issues.push(`${id}: not a day`); continue; } + layout(d); + const prim = d.stops.filter(s => s.state === 'planned'); + const planned = prim.reduce((t, s) => t + s.dur, 0) + d.legs.reduce((t, g) => t + (g?.dur || 0), 0); + const endMin = d.startMin + planned; + const capMin = d.startMin + (d.wakingHours || 11) * 60; + if (endMin > capMin) issues.push(`${d.label}: planned time ends ~${fmt(endMin)} but the waking window closes ~${fmt(capMin)} — ~${Math.round(endMin - capMin)} min over`); + prim.forEach(s => { + if (s.slot === 'lunch' && (s.start < 11 * 60 || s.start > 15 * 60)) issues.push(`${d.label}: lunch at ${s.name} starts ${fmt(s.start)} — outside a normal 11:00–15:00 window`); + if (s.slot === 'dinner' && (s.start < 17 * 60 || s.start > 21 * 60)) issues.push(`${d.label}: dinner at ${s.name} starts ${fmt(s.start)} — outside a normal 17:00–21:00 window`); + }); + const seen = {}; + prim.forEach(s => { const k = s.name.toLowerCase(); if (seen[k] && !s.name.includes('—')) issues.push(`${d.label}: "${s.name}" appears more than once in the plan`); seen[k] = (seen[k] || 0) + 1; }); + if (!prim.length) issues.push(`${d.label}: has no planned stops`); + } + return { issues: issues.length ? issues : ['no problems found'] }; + }, + + // ---- fix-ops (mutations; each commits → undo-able) ---- + add_stop: (a) => { + const SLOTS = ['lunch', 'dinner', 'visit']; + const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"; days are ${Object.keys(days).join(', ')}` }; + const c = candById(a.candidateId); if (!c) return { error: `no candidate "${a.candidateId}" — call search_places first` }; + const slot = SLOTS.includes(String(a.slot || c.cat).toLowerCase()) ? String(a.slot || c.cat).toLowerCase() : c.cat; + if (slot === 'hotel') return { error: 'hotels are stays, not day stops — point the user to the hotel drawer' }; + const stop = makeStop(c, slot, a.state && ['planned', 'maybe', 'alt'].includes(a.state) ? a.state : 'planned'); + const inc = d.stops.find(x => x.slot === slot && x.state === 'planned' && x.id !== stop.id); + if (inc && stop.state === 'planned') inc.state = 'alt'; // one slot, one plan + d.stops.push(stop); + rebuildLegs(d); commit(`[llm] ${stop.name} → ${stateLabel(stop.state)} on ${d.label}`); renderAll([stop.id]); + return { ok: true, action: 'add_stop', stop: stop.name, slot, state: stop.state, day: d.label, demoted: inc ? inc.name : null }; + }, + + remove_stop: (a) => { + const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` }; + const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))]; + d.stops.splice(d.stops.indexOf(stop), 1); + rebuildLegs(d); commit(`[llm] removed ${stop.name} from ${d.label}`); renderAll(); + return { ok: true, action: 'remove_stop', removed: stop.name, day: d.label }; + }, + + move_stop: (a) => { + const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` }; + const to = days[a.toDayId]; if (!to) return { error: `unknown day "${a.toDayId}"` }; + const fromId = Object.keys(days).find(k => days[k].stops.includes(stop)); + days[fromId].stops.splice(days[fromId].stops.indexOf(stop), 1); + to.stops.push(stop); + rebuildLegs(days[fromId]); rebuildLegs(to); + commit(`[llm] moved ${stop.name} → ${to.label}`); renderAll(); + return { ok: true, action: 'move_stop', stop: stop.name, from: days[fromId].label, to: to.label }; + }, + + set_duration: (a) => { + const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` }; + const mins = Math.round(+a.minutes || 0); if (!mins || mins < 5) return { error: 'minutes must be a number ≥ 5' }; + const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))]; + stop.dur = mins; stop.suggested = { value: mins, conf: 4, src: 'assistant' }; + rebuildLegs(d); layout(d); commit(`[llm] ${stop.name} → ${mins} min`); renderAll([stop.id]); + return { ok: true, action: 'set_duration', stop: stop.name, minutes: mins, day: d.label, day_ends: fmt(layout(d)) }; + }, + + set_start: (a) => { + const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"` }; + const m = /^(\d{1,2}):(\d{2})$/.exec(String(a.time || '').trim()); if (!m) return { error: 'time must be HH:MM' }; + const h = +m[1], mn = +m[2]; if (h > 23 || mn > 59) return { error: 'time must be HH:MM' }; + d.startMin = h * 60 + mn; + rebuildLegs(d); layout(d); commit(`[llm] ${d.label} starts ${fmt(d.startMin)}`); renderAll(); + return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) }; + }, +}; + +function llmSystemPrompt() { + const cats = Object.keys(M.candidates); + const regions = Object.keys(REGION_MAP).map(k => REGION_MAP[k].label); + return ( + 'You are the planning assistant inside a trip-planning app. The user is refining a trip and you are its orchestrator. Never invent times, prices, or places — read them from tools. Deterministic tools compute routes and validate the plan; you decide which to call and turn the results into a short, warm answer.\n\n' + + 'You may call TOOLS. To call one, reply with ONLY this exact format — a single fenced json block, nothing else on that turn:\n' + + '\n{"name":"TOOL_NAME","args":{...}}\n\n' + + 'The tool result is then returned to you. From there you either call another tool the same way, or give your final natural-language answer.\n\n' + + 'TOOLS (name — args — what it does):\n' + + ' itinerary_summary() — the current plan: bookings, stays, and each day\'s scheduled stops with times.\n' + + ' search_places(category, region?) — candidate places. category ∈ {' + cats.join(', ') + '} ; region ∈ {' + regions.join(', ') + '} or omit. Returns id, name, price_eur, dur_min, tags, pitch, walk_from_hotel_min.\n' + + ' place_facts(id) — full facts for one place (id or name): price, duration, tags, pitch, walk from hotel, summary.\n' + + ' route_between(from, to) — real walking minutes + km between two places ("hotel" or a place id/name).\n' + + ' review_plan(dayId?) — deterministic checks: day overrun vs the waking window, meals at implausible hours, duplicates. dayId optional.\n' + + ' add_stop(dayId, slot, candidateId, state?) — put a candidate on a day. slot ∈ lunch,dinner,visit. If the slot is taken the current stop is demoted to a backup (a swap). state ∈ planned,idea,backup (default planned). candidateId comes from search_places.\n' + + ' remove_stop(stopId) — remove a stop from its day. stopId is a stop id or its name.\n' + + ' move_stop(stopId, toDayId) — move a stop to another day. stopId is a stop id or its name.\n' + + ' set_duration(stopId, minutes) — change a stop\'s duration in minutes. stopId is a stop id or its name.\n' + + ' set_start(dayId, time) — set a day\'s start time as "HH:MM".\n\n' + + 'Guidelines:\n' + + '- dayId is the day id like "D1" or "D2" — shown in brackets in the plan rows, e.g. [D1 · 2026-09-12]. Never use the calendar date as a dayId.\n' + + '- Answer concisely (1–4 sentences), plain text, no markdown.\n' + + '- For any factual question about times, distances, or prices, CALL the tool — do not guess.\n' + + '- For a requested change, call the matching fix-op, then confirm in one sentence what changed. The user can undo any edit.\n' + + '- Before a big rework, consider review_plan() and mention any issues it reports.\n' + + '- Be efficient: call each tool at most once unless you need to, and never re-fetch facts you already have. A fix-op alone is enough — do not re-search or re-fetch the stop you are moving/editing.\n\n' + + 'Current plan:\n' + llmContext()); +} + +// Parse a {...} block from the model output, if present. +function parseToolBlock(text) { + const m = /\s*([\s\S]*?)\s*<\/tool>/.exec(text); + if (!m) return null; + try { return JSON.parse(m[1]); } catch { return null; } +} +// A subtle activity line so the user sees the model working its tools. +function toolLine(name, args) { + const m = el('div', 'msg tool', '⚙ ' + esc(name) + (args && Object.keys(args).length ? ' ' + esc(JSON.stringify(args)) + '' : '')); + $('#chat-body').append(m); m.scrollIntoView({ behavior: 'smooth', block: 'end' }); + return m; +} + +async function askLLM(v) { scriptBusy = true; - const sys = 'You are the planning assistant inside a trip-planning app. The user is refining a trip; the current plan state is below. ' + - 'Answer the user\'s message concisely (2–5 sentences), using the plan as ground truth — never invent times, prices, or stops. ' + - 'The user drives edits through the app UI; if an edit is implied, say which control does it (e.g. the chat command, the drawer chips on the map, a stop card\'s alternatives). ' + - 'Plain text only, no markdown.\n\nCurrent plan:\n' + llmContext(); - fetch('/llm-chat', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messages: [{ role: 'system', content: sys }, { role: 'user', content: v }] }), - }) - .then(r => r.json()) - .then(j => { - const c = (j.choices?.[0]?.message?.content || '').trim(); - t.remove(); - msg('ai', c ? esc(c).replace(/\n+/g, '
') - : 'The model used its whole token budget thinking and returned no visible answer — try a shorter question.'); - scriptBusy = false; - }) - .catch(() => { t.remove(); msg('ai', 'LLM unreachable right now — falling back to canned replies.'); scriptBusy = false; }); + let t = typing(); + const messages = [{ role: 'system', content: llmSystemPrompt() }, { role: 'user', content: v }]; + const finish = html => { t.remove(); msg('ai', html); scriptBusy = false; }; + try { + let finalText = null; + for (let i = 0; i < LLM_TOOL_MAX; i++) { + const r = await fetch('/llm-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages, max_tokens: 8192 }) }); + const j = await r.json(); + const content = (j.choices?.[0]?.message?.content || '').trim(); + const call = parseToolBlock(content); + if (call) { + t.remove(); + if (TOOLS[call.name]) toolLine(call.name, call.args || {}); + t = typing(); // the assistant keeps "thinking" + messages.push({ role: 'assistant', content }); + let res; + if (TOOLS[call.name]) res = await Promise.resolve(TOOLS[call.name](call.args || {})); + else res = { error: 'unknown tool. available: ' + Object.keys(TOOLS).join(', ') }; + messages.push({ role: 'user', content: '[tool result for ' + call.name + ']\n' + JSON.stringify(res) }); + continue; + } + finalText = content; break; + } + if (finalText === null) finish('I ran out of tool steps without a final answer — please rephrase.'); + else if (finalText) finish(esc(finalText).replace(/\n+/g, '
')); + else finish('The model used its whole token budget thinking and returned no visible answer — try a shorter question.'); + } catch (e) { + t.remove(); + msg('ai', 'LLM call failed — falling back to canned replies.'); + scriptBusy = false; + } } // Google encoded-polyline decoder (OSRM `overview=full` geometry) // OSRM encodes with precision 5 (unlike Google’s default 6) diff --git a/mock/styles.css b/mock/styles.css index c3025b9..bec0e4d 100644 --- a/mock/styles.css +++ b/mock/styles.css @@ -263,6 +263,8 @@ button { font: inherit; } @keyframes pop { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } .msg.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; } .msg.ai { align-self: flex-start; background: #eef0f4; border-bottom-left-radius: 4px; } +.msg.tool { align-self: flex-start; background: #f5f1fb; border: 1px solid #e5dbf2; border-radius: 10px; padding: 6px 10px; font-size: 11.5px; font-family: ui-monospace, Menlo, Consolas, monospace; color: #5d3a8a; max-width: 94%; } +.msg.tool .ta { color: #8a6116; } .msg .src { display: block; font-size: 11px; color: var(--ink2); margin-top: 6px; } .msg .src a { color: var(--blue); text-decoration: none; } .typing { display: inline-flex; gap: 4px; padding: 12px 14px; }