feat: add AgriAgent — real agentic loop with tool use

Multi-step AI agent in src/ag-refine/:
- agent.js: Claude tool-use loop (up to 10 iterations), streams events
  to UI as it thinks and calls tools
- tools.js: 6 live tools — get_reading_list, get_field_profiles,
  get_ingested_files, get_weather (Open-Meteo), lookup_usda_soil
  (USDA SDA API), calculate_gdd
- index.js: chat UI with streaming tool-call display, suggested
  prompts, clear conversation
- Wired as new Agent tab in sidebar (6th tab)
- Also fixes manifest.json paths (dist/ prefix removed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBD2dN2KEjzz3UQFa9hEpu
This commit is contained in:
Claude 2026-06-27 03:58:28 +00:00
parent 86775eacbd
commit 6f94bd7c79
No known key found for this signature in database
5 changed files with 676 additions and 0 deletions

View file

@ -0,0 +1,127 @@
import { sessionGet, KEYS, buildContextBundle } from '../utils/storage.js';
import { TOOL_DEFINITIONS, executeTool } from './tools.js';
const MODEL = 'claude-sonnet-4-6';
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const MAX_ITERATIONS = 10;
/**
* AgrifineAgent agentic loop with tool use.
*
* Runs entirely in the sidebar context via the background worker proxy.
* Each call to run() streams back events via an onEvent callback so the
* UI can update incrementally as the agent thinks and calls tools.
*/
export class AgrifineAgent {
constructor({ onEvent }) {
this.onEvent = onEvent; // ({ type, data }) => void
}
async run(userMessage) {
const apiKey = await sessionGet(KEYS.API_KEY);
if (!apiKey) {
this.onEvent({ type: 'error', data: 'No API key set. Open ⚙ Settings to add your Anthropic key.' });
return;
}
const contextBundle = await buildContextBundle();
const systemPrompt = [
'You are AgriAgent, an expert AI assistant for farm operations management.',
'You have access to the user\'s farm data through tools — always use them before answering.',
'When answering questions about fields, weather, yields, or finances: first query the relevant data, then synthesize a clear answer.',
'Be specific: cite field names, dates, acreage, and numbers from the actual data.',
'For weather queries on a field, always look up the field profile first to get coordinates.',
'',
'FARM CONTEXT (reading list summaries + field profiles):',
contextBundle,
].join('\n');
const messages = [{ role: 'user', content: userMessage }];
this.onEvent({ type: 'thinking', data: 'Analysing your question…' });
for (let i = 0; i < MAX_ITERATIONS; i++) {
const body = {
model: MODEL,
max_tokens: 2048,
system: systemPrompt,
tools: TOOL_DEFINITIONS,
messages,
};
let response;
try {
response = await this._callAPI(apiKey, body);
} catch (err) {
this.onEvent({ type: 'error', data: err.message });
return;
}
// Append assistant turn
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'end_turn') {
// Extract final text
const text = response.content
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('\n');
this.onEvent({ type: 'answer', data: text });
return;
}
if (response.stop_reason === 'tool_use') {
const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
const toolResults = [];
for (const block of toolUseBlocks) {
this.onEvent({ type: 'tool_call', data: { name: block.name, input: block.input } });
let result;
try {
result = await executeTool(block.name, block.input);
} catch (err) {
result = { error: err.message };
}
this.onEvent({ type: 'tool_result', data: { name: block.name, result } });
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify(result),
});
}
messages.push({ role: 'user', content: toolResults });
continue;
}
// Unexpected stop reason
this.onEvent({ type: 'error', data: `Unexpected stop reason: ${response.stop_reason}` });
return;
}
this.onEvent({ type: 'error', data: 'Agent reached maximum iterations without completing.' });
}
async _callAPI(apiKey, body) {
const res = await fetch(ANTHROPIC_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Anthropic API ${res.status}: ${text}`);
}
return res.json();
}
}

View file

@ -0,0 +1,234 @@
import { AgrifineAgent } from './agent.js';
const TOOL_ICONS = {
get_reading_list: '📖',
get_field_profiles: '🌱',
get_ingested_files: '📄',
get_weather: '🌤️',
lookup_usda_soil: '🏛️',
calculate_gdd: '📊',
};
const SUGGESTED_PROMPTS = [
'What are my current field conditions and harvest windows?',
'Which fields have the best soil for carbon sequestration?',
'Summarise all my farm data and flag any issues',
'What does the 7-day weather look like for my fields?',
'What USDA programs might I qualify for based on my fields?',
];
export function AgRefineModule() {
let messages = [];
let isRunning = false;
return {
id: 'ag-refine',
label: 'AgriAgent',
async render(container) {
container.innerHTML = `
<div class="flex flex-col h-full">
<!-- Header bar -->
<div class="px-4 pt-4 pb-2 flex-shrink-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-base">🤖</span>
<h2 class="text-sm font-bold text-gray-800">AgriAgent</h2>
<span class="text-xs px-2 py-0.5 rounded-full bg-agri-100 text-agri-700 font-medium">AI Agent</span>
</div>
<p class="text-xs text-gray-400">Multi-step reasoning over all your farm data</p>
</div>
<!-- Chat history -->
<div id="agent-chat" class="flex-1 overflow-y-auto px-4 py-2 space-y-3"></div>
<!-- Suggested prompts (shown when empty) -->
<div id="agent-suggestions" class="px-4 pb-2 flex-shrink-0">
<p class="text-xs text-gray-400 mb-2">Try asking</p>
<div class="flex flex-col gap-1.5">
${SUGGESTED_PROMPTS.map((p) => `
<button class="suggest-btn text-left text-xs bg-agri-50 hover:bg-agri-100 text-agri-700 px-3 py-2 rounded-lg border border-agri-200 transition">
${p}
</button>`).join('')}
</div>
</div>
<!-- Input bar -->
<div class="flex-shrink-0 border-t border-gray-100 px-3 py-3 bg-white">
<div class="flex gap-2 items-end">
<textarea id="agent-input" rows="2"
placeholder="Ask the agent anything about your farm…"
class="flex-1 text-sm border border-gray-300 rounded-xl px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400 resize-none"></textarea>
<button id="agent-send"
class="flex-shrink-0 bg-agri-600 hover:bg-agri-700 disabled:bg-gray-300 text-white rounded-xl px-3 py-2 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
<button id="agent-clear" class="text-xs text-gray-300 hover:text-gray-500 mt-1 transition">Clear conversation</button>
</div>
</div>
`;
this._bindEvents(container);
this._renderMessages(container);
},
_bindEvents(container) {
const input = container.querySelector('#agent-input');
const sendBtn = container.querySelector('#agent-send');
const send = () => {
const text = input.value.trim();
if (!text || isRunning) return;
input.value = '';
this._runAgent(text, container);
};
sendBtn.addEventListener('click', send);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
container.querySelectorAll('.suggest-btn').forEach((btn) => {
btn.addEventListener('click', () => {
input.value = btn.textContent.trim();
send();
});
});
container.querySelector('#agent-clear').addEventListener('click', () => {
messages = [];
isRunning = false;
this._renderMessages(container);
});
},
async _runAgent(userText, container) {
if (isRunning) return;
isRunning = true;
// Hide suggestions
container.querySelector('#agent-suggestions')?.classList.add('hidden');
// Add user message
messages.push({ role: 'user', text: userText });
this._renderMessages(container);
// Thinking placeholder
const thinkingId = `thinking_${Date.now()}`;
messages.push({ role: 'thinking', id: thinkingId, steps: [] });
this._renderMessages(container);
const thinkingMsg = messages[messages.length - 1];
const agent = new AgrifineAgent({
onEvent: ({ type, data }) => {
if (type === 'thinking') {
thinkingMsg.steps.push({ type: 'status', text: data });
} else if (type === 'tool_call') {
thinkingMsg.steps.push({
type: 'tool',
icon: TOOL_ICONS[data.name] ?? '🔧',
name: data.name.replace(/_/g, ' '),
input: JSON.stringify(data.input),
});
} else if (type === 'tool_result') {
const last = thinkingMsg.steps[thinkingMsg.steps.length - 1];
if (last?.type === 'tool') last.done = true;
} else if (type === 'answer') {
// Replace thinking bubble with final answer
const idx = messages.findIndex((m) => m.id === thinkingId);
if (idx >= 0) messages.splice(idx, 1);
messages.push({ role: 'assistant', text: data });
isRunning = false;
} else if (type === 'error') {
const idx = messages.findIndex((m) => m.id === thinkingId);
if (idx >= 0) messages.splice(idx, 1);
messages.push({ role: 'error', text: data });
isRunning = false;
}
this._renderMessages(container);
},
});
await agent.run(userText);
},
_renderMessages(container) {
const chat = container.querySelector('#agent-chat');
if (!chat) return;
if (messages.length === 0) {
chat.innerHTML = '';
container.querySelector('#agent-suggestions')?.classList.remove('hidden');
return;
}
chat.innerHTML = messages.map((msg) => {
if (msg.role === 'user') {
return `
<div class="flex justify-end">
<div class="max-w-[85%] bg-agri-600 text-white text-sm px-3 py-2 rounded-2xl rounded-tr-sm">
${escapeHtml(msg.text)}
</div>
</div>`;
}
if (msg.role === 'thinking') {
const steps = msg.steps ?? [];
return `
<div class="flex flex-col gap-1.5">
${steps.map((step) => {
if (step.type === 'status') {
return `<div class="flex items-center gap-1.5 text-xs text-gray-400">
<span class="spinner flex-shrink-0"></span> ${escapeHtml(step.text)}
</div>`;
}
if (step.type === 'tool') {
return `<div class="flex items-center gap-2 text-xs bg-earth-50 border border-earth-200 rounded-lg px-3 py-1.5">
<span>${step.icon}</span>
<span class="font-medium text-earth-700">${step.name}</span>
${step.done ? '<span class="ml-auto text-agri-500">✓</span>' : '<span class="spinner ml-auto flex-shrink-0"></span>'}
</div>`;
}
return '';
}).join('')}
${steps.length === 0 ? '<div class="flex items-center gap-1.5 text-xs text-gray-400"><span class="spinner"></span> Starting…</div>' : ''}
</div>`;
}
if (msg.role === 'assistant') {
return `
<div class="flex gap-2 items-start">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-agri-100 flex items-center justify-center text-xs">🤖</div>
<div class="flex-1 bg-white border border-gray-200 rounded-2xl rounded-tl-sm px-3 py-2.5 text-sm text-gray-800 leading-relaxed shadow-sm whitespace-pre-wrap">
${escapeHtml(msg.text)}
</div>
</div>`;
}
if (msg.role === 'error') {
return `
<div class="text-xs bg-red-50 border border-red-200 text-red-700 rounded-xl px-3 py-2">
${escapeHtml(msg.text)}
</div>`;
}
return '';
}).join('');
// Scroll to bottom
chat.scrollTop = chat.scrollHeight;
},
};
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View file

@ -0,0 +1,304 @@
import { getReadingList, getIngestedFiles, getFieldProfiles } from '../utils/storage.js';
// ── Tool definitions sent to Claude ──────────────────────────────────────────
export const TOOL_DEFINITIONS = [
{
name: 'get_reading_list',
description: 'Retrieve saved web pages from the user\'s reading list. Can filter by tag.',
input_schema: {
type: 'object',
properties: {
tag: {
type: 'string',
description: 'Optional tag to filter by: agriculture, equipment, land, carbon, USDA, dairy, finance, weather',
},
},
required: [],
},
},
{
name: 'get_field_profiles',
description: 'Retrieve all farm field profiles including acreage, soil type, coordinates, crop history, and notes.',
input_schema: {
type: 'object',
properties: {
field_name: {
type: 'string',
description: 'Optional field name to filter by (partial match)',
},
},
required: [],
},
},
{
name: 'get_ingested_files',
description: 'Retrieve all uploaded and parsed farm data files (CSV, Excel, PDF). Returns structured JSON extracted from each file.',
input_schema: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['CSV', 'Excel', 'PDF'],
description: 'Optional file type filter',
},
},
required: [],
},
},
{
name: 'get_weather',
description: 'Fetch current conditions, 7-day forecast, and calculate Growing Degree Days (GDD) for a field location using Open-Meteo (free, no key required).',
input_schema: {
type: 'object',
properties: {
latitude: {
type: 'number',
description: 'Latitude of the field',
},
longitude: {
type: 'number',
description: 'Longitude of the field',
},
field_name: {
type: 'string',
description: 'Human-readable field name for context',
},
},
required: ['latitude', 'longitude'],
},
},
{
name: 'lookup_usda_soil',
description: 'Look up soil data from the USDA Web Soil Survey API by coordinates. Returns soil series, texture, organic matter, and drainage class.',
input_schema: {
type: 'object',
properties: {
latitude: {
type: 'number',
description: 'Latitude',
},
longitude: {
type: 'number',
description: 'Longitude',
},
},
required: ['latitude', 'longitude'],
},
},
{
name: 'calculate_gdd',
description: 'Calculate Growing Degree Days from temperature data. Uses base temp of 50°F for forage crops.',
input_schema: {
type: 'object',
properties: {
daily_highs: {
type: 'array',
items: { type: 'number' },
description: 'Array of daily high temperatures in Fahrenheit',
},
daily_lows: {
type: 'array',
items: { type: 'number' },
description: 'Array of daily low temperatures in Fahrenheit',
},
base_temp: {
type: 'number',
description: 'Base temperature in °F (default 50 for forage crops)',
},
},
required: ['daily_highs', 'daily_lows'],
},
},
];
// ── Tool implementations ──────────────────────────────────────────────────────
export async function executeTool(name, input) {
switch (name) {
case 'get_reading_list':
return toolGetReadingList(input);
case 'get_field_profiles':
return toolGetFieldProfiles(input);
case 'get_ingested_files':
return toolGetIngestedFiles(input);
case 'get_weather':
return toolGetWeather(input);
case 'lookup_usda_soil':
return toolLookupUSDAsoil(input);
case 'calculate_gdd':
return toolCalculateGDD(input);
default:
return { error: `Unknown tool: ${name}` };
}
}
async function toolGetReadingList({ tag } = {}) {
const list = await getReadingList();
const filtered = tag ? list.filter((i) => i.tags?.includes(tag)) : list;
return {
count: filtered.length,
items: filtered.slice(0, 30).map((i) => ({
title: i.title,
url: i.url,
summary: i.summary,
tags: i.tags,
savedAt: i.savedAt,
})),
};
}
async function toolGetFieldProfiles({ field_name } = {}) {
const profiles = await getFieldProfiles();
const filtered = field_name
? profiles.filter((p) => p.name.toLowerCase().includes(field_name.toLowerCase()))
: profiles;
return {
count: filtered.length,
profiles: filtered.map((p) => ({
id: p.id,
name: p.name,
acres: p.acres,
soilType: p.soilType,
cluId: p.cluId,
coordinates: p.coordinates,
notes: p.notes,
cropHistory: p.cropHistory,
harvestRecords: p.harvestRecords,
createdAt: p.createdAt,
})),
};
}
async function toolGetIngestedFiles({ type } = {}) {
const files = await getIngestedFiles();
const filtered = type ? files.filter((f) => f.type === type) : files;
return {
count: filtered.length,
files: filtered.map((f) => ({
filename: f.filename,
type: f.type,
uploadedAt: f.uploadedAt,
structuredData: f.structuredData,
})),
};
}
async function toolGetWeather({ latitude, longitude, field_name = 'field' }) {
const url = new URL('https://api.open-meteo.com/v1/forecast');
url.searchParams.set('latitude', latitude);
url.searchParams.set('longitude', longitude);
url.searchParams.set('current', 'temperature_2m,precipitation,wind_speed_10m,weather_code');
url.searchParams.set('daily', 'temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max');
url.searchParams.set('temperature_unit', 'fahrenheit');
url.searchParams.set('wind_speed_unit', 'mph');
url.searchParams.set('precipitation_unit', 'inch');
url.searchParams.set('forecast_days', '7');
url.searchParams.set('timezone', 'auto');
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`Open-Meteo error: ${res.status}`);
const data = await res.json();
const current = data.current;
const daily = data.daily;
// GDD accumulation from forecast
const gddDays = daily.temperature_2m_max.map((hi, i) => {
const lo = daily.temperature_2m_min[i];
return Math.max(0, ((hi + lo) / 2) - 50);
});
const totalGDD = gddDays.reduce((a, b) => a + b, 0);
// Rain alert: >0.5 inch in next 48h
const rainAlert = (daily.precipitation_sum[0] ?? 0) + (daily.precipitation_sum[1] ?? 0) > 0.5;
// Harvest window
const avgRainProb = (daily.precipitation_probability_max.slice(0, 3).reduce((a, b) => a + b, 0) / 3);
const harvestWindow = avgRainProb < 20 ? 'GREEN' : avgRainProb < 50 ? 'YELLOW' : 'RED';
return {
field: field_name,
coordinates: { latitude, longitude },
current: {
temperature_f: current.temperature_2m,
precipitation_in: current.precipitation,
wind_mph: current.wind_speed_10m,
},
forecast_7day: daily.time.map((date, i) => ({
date,
high_f: daily.temperature_2m_max[i],
low_f: daily.temperature_2m_min[i],
precip_in: daily.precipitation_sum[i],
rain_probability_pct: daily.precipitation_probability_max[i],
gdd: gddDays[i].toFixed(1),
})),
gdd_7day_total: totalGDD.toFixed(1),
rain_alert_48h: rainAlert,
harvest_window: harvestWindow,
};
}
async function toolLookupUSDAsoil({ latitude, longitude }) {
// USDA Web Soil Survey SDA REST API
const query = `SELECT mapunit.muname, component.compname, component.comppct_r,
component.taxorder, component.taxsubgrp, chorizon.texture, chorizon.om_r,
chorizon.drainagecl
FROM mapunit
INNER JOIN component ON mapunit.mukey = component.mukey
INNER JOIN chorizon ON component.cokey = chorizon.cokey
WHERE mu_lks.mukey IN (
SELECT * FROM SDA_Get_Mukey_from_intersection_with_WktWgs84(
'point(${longitude} ${latitude})')
)
AND component.majcompflag = 'Yes'
ORDER BY component.comppct_r DESC`;
try {
const res = await fetch('https://sdmdataaccess.sc.egov.usda.gov/TABULAR/post.rest', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `request=query&query=${encodeURIComponent(query)}&format=JSON`,
});
const data = await res.json();
const rows = data.Table ?? [];
return {
coordinates: { latitude, longitude },
soil_data: rows.slice(0, 5).map((r) => ({
map_unit: r[0],
component: r[1],
percent: r[2],
tax_order: r[3],
subgroup: r[4],
texture: r[5],
organic_matter_pct: r[6],
drainage_class: r[7],
})),
};
} catch (_) {
return {
coordinates: { latitude, longitude },
note: 'USDA SDA API unavailable — soil data requires network access from background worker',
};
}
}
function toolCalculateGDD({ daily_highs, daily_lows, base_temp = 50 }) {
const gdd_per_day = daily_highs.map((hi, i) => {
const lo = daily_lows[i] ?? hi;
const avg = (hi + lo) / 2;
return Math.max(0, avg - base_temp);
});
const total = gdd_per_day.reduce((a, b) => a + b, 0);
return {
base_temp_f: base_temp,
days: gdd_per_day.length,
gdd_per_day: gdd_per_day.map((g) => parseFloat(g.toFixed(1))),
total_gdd: parseFloat(total.toFixed(1)),
interpretation:
total < 200 ? 'Early growth stage' :
total < 500 ? 'Vegetative growth' :
total < 900 ? 'Approaching harvest window' :
'Harvest recommended',
};
}

View file

@ -4,6 +4,7 @@ import { DataIngestModule } from '../modules/data-ingest/index.js';
import { FieldProfileModule } from '../modules/field-profile/index.js';
import { DashboardModule } from '../modules/dashboard/index.js';
import { CarbonEstimatorModule } from '../modules/carbon-estimator/index.js';
import { AgRefineModule } from '../ag-refine/index.js';
import { sessionSet, sessionGet, KEYS } from '../utils/storage.js';
// ── Module registry ───────────────────────────────────────────────────────────
@ -13,6 +14,7 @@ const MODULES = [
FieldProfileModule(),
DashboardModule(),
CarbonEstimatorModule(),
AgRefineModule(),
];
const moduleMap = Object.fromEntries(MODULES.map((m) => [m.id, m]));

View file

@ -86,6 +86,15 @@
</svg>
<span>Carbon</span>
</button>
<button data-tab="ag-refine"
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>Agent</span>
</button>
</div>
</nav>