Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/better-pants-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-agents": patch
---

Migrate from @google/adk to LangChain
21 changes: 21 additions & 0 deletions fdm-agents/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# -------------------------------------
# Gemini / Google AI Configuration
# -------------------------------------
# API key for the Gemini model used by Gerrit.
# Required: Yes
GEMINI_API_KEY=

# -------------------------------------
# LangSmith Tracing (optional, development)
# -------------------------------------
# Enable LangSmith tracing for full observability of agent reasoning,
# tool calls, token usage, and latency. No code changes required —
# set these env vars and all LangGraph runs are automatically traced.
# Obtain an API key at https://smith.langchain.com/
# Required: No (omit or leave empty to disable)
LANGSMITH_TRACING=false
LANGCHAIN_API_KEY=
LANGCHAIN_PROJECT=fdm-agents
# Endpoint for LangSmith API (default: https://api.smith.langchain.com)
# Use https://eu.api.smith.langchain.com for EU data residency.
LANGSMITH_ENDPOINT=
8 changes: 6 additions & 2 deletions fdm-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@
"test-coverage": "vitest run --coverage"
},
"dependencies": {
"@google/adk": "^1.1.0",
"@langchain/core": "^1.1.46",
"@langchain/google-genai": "^2.1.30",
"@langchain/langgraph": "^1.3.0",
"@nmi-agro/fdm-calculator": "workspace:^",
"@nmi-agro/fdm-core": "workspace:^",
"@nmi-agro/fdm-data": "workspace:^",
"posthog-node": "^5.33.4",
"@posthog/ai": "^7.18.4",
"langchain": "^1.4.0",
"posthog-node": "^5.33.7",
"zod": "^4.4.3"
},
"engines": {
Expand Down
87 changes: 73 additions & 14 deletions fdm-agents/src/agents/gerrit/agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { AIMessage } from "@langchain/core/messages"
import { describe, expect, it, vi } from "vitest"
import { createFertilizerPlannerAgent } from "./agent"
import {
DEFAULT_TOOL_ROUND_LIMIT,
GERRIT_DESCRIPTION,
GERRIT_INSTRUCTION,
GERRIT_NAME,
TOOL_LIMIT_WARNING,
countToolRoundtrips,
createFertilizerPlannerAgent,
} from "./agent"

// Mock models and tools to avoid external calls
vi.mock("../../models/default", () => ({
Expand All @@ -11,22 +20,16 @@ vi.mock("../../tools/fertilizer-planner", () => ({
}))

describe("Gerrit Agent", () => {
it("should create a Fertilizer Planner Agent with correct name", () => {
const mockFdm = {} as any
const agent = createFertilizerPlannerAgent(mockFdm, "fake-api-key")

expect(agent.name).toBe("Gerrit")
expect(agent.description).toContain("Dutch Agronomist")
it("should have the correct name and description", () => {
expect(GERRIT_NAME).toBe("Gerrit")
expect(GERRIT_DESCRIPTION).toContain("Dutch Agronomist")
})

it("should have instruction containing critical constraints", () => {
const mockFdm = {} as any
const agent = createFertilizerPlannerAgent(mockFdm, "fake-api-key")

expect(agent.instruction).toContain("LEGAL NORMS")
expect(agent.instruction).toContain("BUFFER STRIPS")
expect(agent.instruction).toContain("ROTATION LEVEL")
expect(agent.instruction).toContain("SECURITY & CONTEXT BOUNDARIES")
expect(GERRIT_INSTRUCTION).toContain("LEGAL NORMS")
expect(GERRIT_INSTRUCTION).toContain("BUFFER STRIPS")
expect(GERRIT_INSTRUCTION).toContain("ROTATION LEVEL")
expect(GERRIT_INSTRUCTION).toContain("SECURITY & CONTEXT BOUNDARIES")
})

it("should throw when no API key is provided and GEMINI_API_KEY env is not set", () => {
Expand All @@ -37,4 +40,60 @@ describe("Gerrit Agent", () => {
)
vi.unstubAllEnvs()
})

it("should export a default tool round limit", () => {
expect(DEFAULT_TOOL_ROUND_LIMIT).toBe(40)
})

it("should export a tool limit warning message", () => {
expect(TOOL_LIMIT_WARNING).toContain("final fertilizer plan NOW")
})
})

describe("countToolRoundtrips", () => {
it("should return 0 for empty messages", () => {
expect(countToolRoundtrips([])).toBe(0)
})

it("should count AI messages with tool calls", () => {
const messages = [
new AIMessage({
content: "",
tool_calls: [
{ name: "getFarmFields", args: {}, id: "1", type: "tool_call" },
],
}),
new AIMessage({
content: "",
tool_calls: [
{
name: "simulateFarmPlan",
args: {},
id: "2",
type: "tool_call",
},
],
}),
new AIMessage({ content: "Final answer" }),
]
expect(countToolRoundtrips(messages)).toBe(2)
})

it("should not count AI messages without tool calls", () => {
const messages = [new AIMessage({ content: "Just talking" })]
expect(countToolRoundtrips(messages)).toBe(0)
})

it("should count a single AI message with multiple parallel tool calls as one roundtrip", () => {
const messages = [
new AIMessage({
content: "",
tool_calls: [
{ name: "toolA", args: {}, id: "1", type: "tool_call" },
{ name: "toolB", args: {}, id: "2", type: "tool_call" },
],
}),
]
expect(countToolRoundtrips(messages)).toBe(1)
})
})
120 changes: 89 additions & 31 deletions fdm-agents/src/agents/gerrit/agent.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,22 @@
import { Agent } from "@google/adk"
import { AIMessage } from "@langchain/core/messages"
import type { BaseMessage } from "@langchain/core/messages"
import { createAgent, dynamicSystemPromptMiddleware, toolStrategy } from "langchain"
import type { FdmType } from "@nmi-agro/fdm-core"
import { createDefaultModel } from "../../models/default"
import { createFertilizerPlannerTools } from "../../tools/fertilizer-planner"
import { FertilizerPlanSchema } from "./schema"

/**
* Creates the Fertilizer Application Planner Agent: "Gerrit"
* @param fdm The non-serializable FDM database instance.
* @param apiKey Optional API key for the Gemini model.
* @param model Optional model name override.
*/
export function createFertilizerPlannerAgent(
fdm: FdmType,
apiKey?: string,
model?: string,
) {
const resolvedKey = apiKey ?? process.env.GEMINI_API_KEY
if (!resolvedKey) {
throw new Error(
"Missing Gemini API key: provide apiKey or set the GEMINI_API_KEY environment variable.",
)
}
return new Agent({
name: "Gerrit",
description:
"Expert Dutch Agronomist for fertilizer application planning.",
model: createDefaultModel(resolvedKey, model),
instruction: `You are Gerrit, an expert Dutch Agronomist.
export const GERRIT_NAME = "Gerrit"
export const GERRIT_DESCRIPTION =
"Expert Dutch Agronomist for fertilizer application planning."

/** Default soft limit on tool roundtrips before the agent is warned to wrap up. */
export const DEFAULT_TOOL_ROUND_LIMIT = 40

export const TOOL_LIMIT_WARNING =
"IMPORTANT: You are approaching the maximum number of allowed tool calls. STOP calling planning, simulation, and search tools. You MUST produce your final fertilizer plan NOW using the required structured JSON format."

export const GERRIT_INSTRUCTION = `You are Gerrit, an expert Dutch Agronomist.
Your goal is to create a legally compliant and agronomically sound fertilizer plan for the entire farm.

IMPORTANT CONSTRAINTS:
Expand All @@ -48,6 +39,12 @@ IMPORTANT CONSTRAINTS:
10. ORGANIC FARMING: If "Organic Farming" is YES, you MUST NOT use any mineral fertilizers ("p_type": "mineral") in the plan.
11. MANURE FILLING STRATEGY:
- If "Fill Manure Space" is YES: Maximize manure applications up to the farm-level legal norm, even if it exceeds agronomic advice or field-level norms, provided it doesn't violate other legal norms (like Phosphate). Prefer to use manures that are general available in the regions, e.g. 'Rundeveedrijfmest'. Do this at the farm level; individual fields can receive more manure than their field-level norm as long as the farm total is compliant.
- CRITICAL WORKFLOW for "Fill Manure Space" = YES:
1. After fetching legal norms, calculate: totalManureNorm_kg = sum of (field manure norm kg/ha × field area ha) for all productive fields.
2. Choose a manure product (e.g. Rundveedrijfmest) and look up its N content (p_n_rt).
3. Compute the target m³/ha to fill ~95% of the manure space: target_m3_per_ha = (totalManureNorm_kg × 0.95) / (totalProductiveArea_ha × p_n_rt × density / 1000).
4. Split this into realistic applications (e.g. 2-3 gifts of 15-30 m³/ha each).
5. After simulation, check farmTotals.normsFilling.manure vs farmTotals.norms.manure. If less than 90% filled, INCREASE amounts and re-simulate. Do NOT accept under-filled manure space.
- If "Fill Manure Space" is NO: Use manure only as needed for agronomic advice and organic matter balance.
12. AMMONIA REDUCTION: If "Reduce NH3 Emissions" is YES, prioritize fertilizers and application methods with lower ammonia emission factors (p_ef_nh3) for the farm as a whole. Prefer methods like "incorporation" or "injection" over "broadcasting" where the fertilizer allows it.
13. NITROGEN BALANCE TARGET: If "Keep Nitrogen Balance Below Target" is YES, you MUST ensure that the calculated nitrogen balance surplus (the amount of nitrogen applied that is not taken up by the crop or lost to emissions) stays below the environmental target for the farm as a whole. Individual fields may exceed their target if compensated by other fields. Use the simulation tool to monitor the "farmTotals.nBalance" and "target" values.
Expand All @@ -58,12 +55,13 @@ IMPORTANT CONSTRAINTS:
16. DEROGATION: If "Derogation" is YES, you MUST NOT use any mineral fertilizers ("p_type": "mineral") that contain phosphate ("p_p_rt" > 0). Mineral fertilizers that contain no phosphate (e.g., KAS, ureum, pure potassium fertilizers) are still permitted.

Use the tools provided to:
- Fetch the list of fields for the farm using "getFarmFields" (this returns the main cultivation for each field based on the May 15th rule).
- Immediately after "getFarmFields", call "getCropFertilizerGuide" with all unique "b_lu_catalogue" values from the fields. Use the returned crop-specific guidance throughout the plan — it specifies preferred products, nutrients to avoid (e.g. Cl for potatoes), required nutrients (e.g. S for brassicas, B for sugar beet), and split N timing.
- The FARM FIELDS list is already pre-loaded in the user message. You do NOT need to call "getFarmFields" — use the pre-loaded data directly. If the pre-loaded list is empty or missing, only then call "getFarmFields".
- Immediately call "getCropFertilizerGuide" with all unique "b_lu_catalogue" values from the pre-loaded fields. Use the returned crop-specific guidance throughout the plan — it specifies preferred products, nutrients to avoid (e.g. Cl for potatoes), required nutrients (e.g. S for brassicas, B for sugar beet), and split N timing.
- Fetch agronomic advice for all nutrients.
- Fetch the three legal norms for each field and the farm.
- Search for available fertilizer products in the catalogue and farm inventory.
- Simulate your proposed distribution to ensure compliance and monitor the organic matter and nitrogen balances. Pass the cultivation details you received from "getFarmFields" into "simulateFarmPlan".
- Simulate your proposed distribution to ensure compliance and monitor the organic matter and nitrogen balances. Pass the cultivation details from the pre-loaded fields into "simulateFarmPlan".
- If the simulation returns "agronomicWarnings" about unused manure space, you MUST increase manure amounts and re-simulate. Do NOT finalize the plan with unfilled manure space when the "Fill Manure Space" strategy is YES.

OUTPUT FORMAT:
Your final response MUST be a JSON object with exactly this structure (all fields required unless marked optional):
Expand Down Expand Up @@ -102,8 +100,8 @@ Your final response MUST be a JSON object with exactly this structure (all field
}

Rules:
- "summary": Provide a clear, concise and professional narrative in Dutch (< 250 words) tailored for farmers and agricultural advisors (CEFR B2 level). Speak as an expert agronomist explaining the reasoning behind the plan. Be direct and to the point. Focus on the agronomical reasoning: why these fertilizers, the balance of nutrients, and soil health (organic matter). Feel free to use agricultural jargon and policy terms that Dutch farmers are familiar with. Refer to "goede landbouwpraktijk" where applicable. Avoid generic or redundant opening sentences (e.g., "Als agronoom heb ik...", "Hieronder volgt..."). Use the names of fertilizers, cultivations and fields when you need to mention them, NEVER mention the ID's. DO NOT use IT jargon, internal strategy keys, or database IDs. Focus on what the farmer needs to know about the resulting plan.
- "fieldSummary": Write a short Dutch explanation (≤ 75 words) specific to this field. Explain the key choices: which fertilizer(s) were selected and why (including any crop-specific preferences or avoidances from the getCropFertilizerGuide skill, e.g. K₂SO₄ over KCl for potatoes to protect onderwatergewicht, required S for brassicas, B for sugar beet), the split-application timing rationale, and the chosen application method. Mention any field-specific consideration the farmer should be aware of (soil type risk, crop quality target, norm constraint). Do NOT repeat farm-level totals or the overall plan summary.
- "summary": Provide a clear, concise and professional narrative in Dutch (< 250 words) tailored for farmers and agricultural advisors (CEFR B2 level). Speak as an expert agronomist explaining the reasoning behind the plan. Be direct and to the point. Focus on the agronomical reasoning: why these fertilizers, the balance of nutrients, and soil health (organic matter). Feel free to use agricultural jargon and policy terms that Dutch farmers are familiar with. Refer to "goede landbouwpraktijk" where applicable. Avoid generic or redundant opening sentences (e.g., "Als agronoom heb ik...", "Hieronder volgt..."). Use the names of fertilizers, cultivations and fields when you need to mention them, NEVER mention the ID's. DO NOT use IT jargon, internal strategy keys, or database IDs. Focus on what the farmer needs to know about the resulting plan. LANGUAGE: Write exclusively in Dutch using standard Dutch agricultural terminology. Translate any English concepts from these instructions to their Dutch equivalents (e.g., "farm-level" → "bedrijfsniveau", "workable nitrogen" → "werkzame stikstof", "compliance" → "naleving", "organic matter balance" → "organische stofbalans"). Never mix English terms into the Dutch text.
- "fieldSummary": Write a short Dutch explanation (≤ 75 words) specific to this field. Explain the key choices: which fertilizer(s) were selected and why (including any crop-specific preferences or avoidances from the getCropFertilizerGuide skill, e.g. K₂SO₄ over KCl for potatoes to protect onderwatergewicht, required S for brassicas, B for sugar beet), the split-application timing rationale, and the chosen application method. Mention any field-specific consideration the farmer should be aware of (soil type risk, crop quality target, norm constraint). Do NOT repeat farm-level totals or the overall plan summary. Same LANGUAGE rule as "summary": use only Dutch agricultural terminology, never English terms.
- "metrics.farmTotals": Copy the farmTotals values directly from the final simulateFarmPlan result.
- "plan": Include one entry per field b_id for every field that has at least one application. In rotation mode (Rule 14) this means ALL fields in each cultivation group must have their own entry. Buffer strips MUST NOT appear. Do NOT include fieldMetrics in the output — the server recomputes all metrics independently.
- DO NOT include any text before or after the JSON object.
Expand Down Expand Up @@ -151,7 +149,67 @@ TOOL RETURN SHAPES:
Use "proposedDose.p_dose_nw" (werkzame stikstof, kg/ha) to compare against "advice.d_n_req" — this is the agronomically correct workable-N value. "proposedDose.p_dose_n" is total N and is provided for reference only.
If "isValid" is false, you MUST read the "complianceIssues" array. It contains exact string messages explaining which hard legal norms you violated (and by how many kg). You must adjust your plan to fix these issues in the next iteration.
You should also read "agronomicWarnings", which provides hints on soft limits (like organic matter balance, nitrogen targets, or filling manure space). Use these warnings to refine your plan to better match the requested strategies.
`,
tools: createFertilizerPlannerTools(fdm),
`

/**
* Counts the number of tool roundtrips in the message history.
* A tool roundtrip is an AI message that requested tool calls.
*/
export function countToolRoundtrips(messages: readonly BaseMessage[]): number {
let count = 0
for (const msg of messages) {
if (
AIMessage.isInstance(msg) &&
msg.tool_calls &&
msg.tool_calls.length > 0
) {
count++
}
}
return count
}

/**
* Minimal interface for an agent that can be streamed through runOneShotAgent.
* Using an explicit structural type prevents leaking internal fdm-calculator
* types (e.g. DierlijkeMestGebruiksnormResult) into the package's declaration files.
*/
export type AgentGraph = {
stream(input: unknown, options?: unknown): Promise<AsyncIterable<unknown>>
}

/**
* Creates the Fertilizer Application Planner Agent: "Gerrit"
* @param fdm The non-serializable FDM database instance.
* @param apiKey Optional API key for the Gemini model.
* @param model Optional model name override.
* @param toolRoundLimit Soft limit on tool roundtrips before the agent is warned to finalize (default: 40).
*/
export function createFertilizerPlannerAgent(
fdm: FdmType,
apiKey?: string,
modelName?: string,
toolRoundLimit: number = DEFAULT_TOOL_ROUND_LIMIT,
): AgentGraph {
const resolvedKey = apiKey ?? process.env.GEMINI_API_KEY
if (!resolvedKey) {
throw new Error(
"Missing Gemini API key: provide apiKey or set the GEMINI_API_KEY environment variable.",
)
}
const toolLimitMiddleware = dynamicSystemPromptMiddleware((state) => {
const rounds = countToolRoundtrips(state.messages)
return rounds >= toolRoundLimit
? `${GERRIT_INSTRUCTION}\n\n${TOOL_LIMIT_WARNING}`
: GERRIT_INSTRUCTION
})

return createAgent({
name: GERRIT_NAME,
description: GERRIT_DESCRIPTION,
model: createDefaultModel(resolvedKey, modelName),
tools: createFertilizerPlannerTools(fdm),
responseFormat: toolStrategy(FertilizerPlanSchema),
middleware: [toolLimitMiddleware],
}) as unknown as AgentGraph
}
Loading
Loading