mock: give the chat assistant a tool-calling agent loop

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 <tool>{"name","args"}</tool>,
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.
This commit is contained in:
Greg Pomerantz 2026-09-09 11:48:00 -04:00
parent af63297297
commit c7b5ecb8da
2 changed files with 242 additions and 20 deletions

View File

@ -60,30 +60,250 @@ function llmContext() {
layout(dd); layout(dd);
const stops = dd.stops.filter(s => s.state === 'planned').map( 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 : ''})`); 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'); return L.join('\n');
} }
function askLLM(v) { // ---------------- LLM agent: tools + calling loop ----------------
const t = typing(); // 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
// <tool>{"name":...,"args":{...}}</tool>, 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:0015: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:0021: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' +
'<tool>\n{"name":"TOOL_NAME","args":{...}}\n</tool>\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 (14 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 <tool>{...}</tool> block from the model output, if present.
function parseToolBlock(text) {
const m = /<tool>\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 ? ' <span class="ta">' + esc(JSON.stringify(args)) + '</span>' : ''));
$('#chat-body').append(m); m.scrollIntoView({ behavior: 'smooth', block: 'end' });
return m;
}
async function askLLM(v) {
scriptBusy = true; 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. ' + let t = typing();
'Answer the user\'s message concisely (25 sentences), using the plan as ground truth — never invent times, prices, or stops. ' + const messages = [{ role: 'system', content: llmSystemPrompt() }, { role: 'user', content: v }];
'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). ' + const finish = html => { t.remove(); msg('ai', html); scriptBusy = false; };
'Plain text only, no markdown.\n\nCurrent plan:\n' + llmContext(); try {
fetch('/llm-chat', { let finalText = null;
method: 'POST', headers: { 'Content-Type': 'application/json' }, for (let i = 0; i < LLM_TOOL_MAX; i++) {
body: JSON.stringify({ messages: [{ role: 'system', content: sys }, { role: 'user', content: v }] }), 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();
.then(r => r.json()) const content = (j.choices?.[0]?.message?.content || '').trim();
.then(j => { const call = parseToolBlock(content);
const c = (j.choices?.[0]?.message?.content || '').trim(); if (call) {
t.remove(); t.remove();
msg('ai', c ? esc(c).replace(/\n+/g, '<br>') if (TOOLS[call.name]) toolLine(call.name, call.args || {});
: 'The model used its whole token budget thinking and returned no visible answer — try a shorter question.'); t = typing(); // the assistant keeps "thinking"
scriptBusy = false; messages.push({ role: 'assistant', content });
}) let res;
.catch(() => { t.remove(); msg('ai', 'LLM unreachable right now — falling back to canned replies.'); scriptBusy = false; }); 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, '<br>'));
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) // Google encoded-polyline decoder (OSRM `overview=full` geometry)
// OSRM encodes with precision 5 (unlike Googles default 6) // OSRM encodes with precision 5 (unlike Googles default 6)

View File

@ -263,6 +263,8 @@ button { font: inherit; }
@keyframes pop { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } @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.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.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 { display: block; font-size: 11px; color: var(--ink2); margin-top: 6px; }
.msg .src a { color: var(--blue); text-decoration: none; } .msg .src a { color: var(--blue); text-decoration: none; }
.typing { display: inline-flex; gap: 4px; padding: 12px 14px; } .typing { display: inline-flex; gap: 4px; padding: 12px 14px; }