// WEED-SIM Agent — browser tool-calling loop over the live API via HF providers.
const $ = (id) => document.getElementById(id);
const chat = $('chat');
let MODELS = [];
const SYSTEM = `You are the WEED-SIM Agent — an autonomous orchestration agent for a cannabis-strain breeding simulator. You operate the user's LIVE session through tools, and you teach as you go.
DOMAIN. Specimens (germplasm accessions) have: id, name, type (Indica/Sativa/Hybrid), growth stage (SEED -> SEEDLING -> MATURE), and genetics expressed as THC %, CBD %, Yield (g), GrowTime (days), plus BudColor and LeafColor as HSV hues (the bud/cola is the distinctive strain color). Each trait is an allele pair with a stability (high stability breeds true). Numeric traits inherit by dominance-weighted averaging; colors blend in HSV, so crossing distant hues yields a new vivid hue, not mud. Each specimen has a generation (F0 founders -> Fn) and a pedigree, and can breed/clone up to 2 times.
RULES. To breed or clone, a specimen should be MATURE: grow a SEED twice (SEED->SEEDLING->MATURE). ALWAYS inspect state (list_inventory or germplasm) before acting so you use real ids. Chain tool calls to accomplish a goal; take initiative. Do NOT call the same tool repeatedly with identical arguments — one call returns everything; reuse that result.
STYLE. Be a teacher. Briefly state your PLAN before acting, then after tool results explain what happened and what it means so the user learns the system. Keep prose tight. For analysis/filtering, call germplasm and reason over the data (e.g. purple buds = BudHue ~270-330, stable lines = Stability > 0.8, potent = high THC). End with a short summary.`;
const TOOLS = [
fn('load_starters', 'Add the 12 baseline strains to the registry.', {}),
fn('list_inventory', 'List current specimens (id, name, type, stage, THC, generation).', {}),
fn('clear_inventory', 'Empty the registry.', {}),
fn('grow', 'Advance one specimen by one growth stage.', { seed_id: ['string', 'specimen id'] }, ['seed_id']),
fn('breed', 'Cross two specimens into an offspring.', { parent1_id: ['string', 'id'], parent2_id: ['string', 'id'] }, ['parent1_id', 'parent2_id']),
fn('clone', 'Vegetatively copy one specimen.', { seed_id: ['string', 'id'] }, ['seed_id']),
fn('germplasm', 'Full analytics dataset of every specimen (traits, hues, generation, pedigree) for filtering/analysis.', {}),
fn('predict_cross', 'Monte-Carlo predicted offspring trait + color ranges for two parents (does not create anything).', { parent1_id: ['string', 'id'], parent2_id: ['string', 'id'], n: ['integer', 'samples, default 200'] }, ['parent1_id', 'parent2_id']),
fn('bus_signals', 'Read the Observer Bus signal inbox (recent crosses as provenance receipts).', {}),
];
function fn(name, description, props, required = []) {
const properties = {};
for (const [k, [type, desc]] of Object.entries(props)) properties[k] = { type, description: desc };
return { type: 'function', function: { name, description, parameters: { type: 'object', properties, required } } };
}
// ---- tool execution against the live WEED-SIM API (session header auto-added) ----
async function api(path, method = 'GET', body) {
const r = await fetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
const t = await r.text();
let d; try { d = JSON.parse(t); } catch { d = t; }
if (!r.ok) throw new Error(typeof d === 'object' ? (d.detail || JSON.stringify(d)) : d);
return d;
}
function trimSeed(s) {
return { id: s.id, name: s.name, type: s.type, stage: s.stage, THC: s.thc, CBD: s.cbd, Yield: s.yield, GrowTime: s.grow_time, gen: s.lineage && s.lineage[0] ? '>=F1' : 'F0', can_attempt: s.can_attempt };
}
const TOOL_IMPL = {
load_starters: async () => { await api('/api/inventory/starter', 'POST'); return { ok: true, note: '12 starters added' }; },
list_inventory: async () => ({ seeds: (await api('/api/inventory')).seeds.map(trimSeed) }),
clear_inventory: async () => { await api('/api/inventory/clear', 'POST'); return { ok: true }; },
grow: async (a) => api(`/api/seed/${a.seed_id}/grow`, 'POST'),
breed: async (a) => api('/api/breed', 'POST', { parent1_id: a.parent1_id, parent2_id: a.parent2_id }),
clone: async (a) => api('/api/clone', 'POST', { seed_id: a.seed_id }),
germplasm: async () => {
const g = await api('/api/lab/germplasm');
return { count: g.count, facets: g.facets, records: g.records.map(r => ({ id: r.germplasmDbId, name: r.germplasmName, type: r.germplasmType, gen: r.generation, stage: r.stage, THC: r.THC, CBD: r.CBD, Yield: r.Yield, GrowTime: r.GrowTime, BudHue: r.BudHue, LeafHue: r.LeafHue, budFamily: r.budFamily, Stability: r.Stability, pedigree: r.pedigree, can_attempt: r.canAttempt })) };
},
predict_cross: async (a) => {
const p = await api('/api/lab/predict-cross', 'POST', { parent1_id: a.parent1_id, parent2_id: a.parent2_id, n: a.n || 200 });
return { parents: p.parents, THC: { mean: p.THC.mean, range: [p.THC.min, p.THC.max] }, CBD: { mean: p.CBD.mean, range: [p.CBD.min, p.CBD.max] }, Yield: { mean: p.Yield.mean }, GrowTime: { mean: p.GrowTime.mean } };
},
bus_signals: async () => { const s = await api('/api/deck/signals'); return { available: s.available, count: s.count, signals: (s.signals || []).map(x => ({ lane: x.lane, title: x.title, msg: x.message })) }; },
};
// ---- chat rendering ----
function bubble(cls, html) { const d = document.createElement('div'); d.className = 'bubble ' + cls; d.innerHTML = html; chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d; }
function esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
function fmt(s) { return esc(s).replace(/\*\*(.+?)\*\*/g, '$1').replace(/\n/g, '
'); }
function toolCard(name, args, result, err) {
const d = document.createElement('details'); d.className = 'tool'; d.open = false;
d.innerHTML = `
${esc(typeof result === 'string' ? result : JSON.stringify(result, null, 2))}`;
chat.appendChild(d); chat.scrollTop = chat.scrollHeight;
}
// ---- LLM call ----
async function callLLM(messages) {
const token = $('token').value.trim();
if (!token) throw new Error('Paste your HF token first (top bar).');
const r = await fetch('/api/agent/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-HF-Token': token },
body: JSON.stringify({ provider: $('provider').value, model: $('model').value.trim(), messages, tools: TOOLS, tool_choice: 'auto' }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
return d.choices[0].message;
}
// ---- agent loop ----
let messages = [{ role: 'system', content: SYSTEM }];
let busy = false;
async function send() {
if (busy) return;
const text = $('msg').value.trim();
if (!text) return;
$('msg').value = '';
bubble('user', fmt(text));
messages.push({ role: 'user', content: text });
busy = true; $('send').disabled = true;
const think = document.createElement('div'); think.className = 'thinking'; think.textContent = 'thinking'; chat.appendChild(think);
try {
for (let step = 0; step < 12; step++) {
const msg = await callLLM(messages);
messages.push(msg);
if (msg.content) bubble('assistant', fmt(msg.content));
const calls = msg.tool_calls || [];
if (!calls.length) break;
for (const tc of calls) {
let args = {}; try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
let result, err = false;
try { result = await (TOOL_IMPL[tc.function.name] || (async () => ({ error: 'unknown tool' })))(args); }
catch (e) { result = { error: String(e.message || e) }; err = true; }
toolCard(tc.function.name, args, result, err);
messages.push({ role: 'tool', tool_call_id: tc.id, name: tc.function.name, content: JSON.stringify(result).slice(0, 6000) });
}
}
} catch (e) {
bubble('error', 'Agent error: ' + esc(e.message || e) + '