Files
yacy_search_server/source/net/yacy/ai/ToolProvider.java
Michael Peter Christen 2141aef2c8 added skill configuration: we present llm tools as "Skills" and make
them configurable in the YaCy GUI.
2026-02-08 21:44:04 +01:00

339 lines
13 KiB
Java

/**
* ToolProvider
* Copyright 2026 by Michael Peter Christen
* First released 06.02.2026 at https://yacy.net
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program in the file lgpl21.txt
* If not, see <http://www.gnu.org/licenses/>.
*/
package net.yacy.ai;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import net.yacy.ai.tools.CalculatorTool;
import net.yacy.ai.tools.ChitChatTool;
import net.yacy.ai.tools.DateMathTool;
import net.yacy.ai.tools.DateTimeTool;
import net.yacy.ai.tools.HttpJsonTool;
import net.yacy.ai.tools.Mermaid2ASCIITool;
import net.yacy.ai.tools.NumberParserTool;
import net.yacy.ai.tools.PromptToMermaidTool;
import net.yacy.ai.tools.SelfReflectTool;
import net.yacy.ai.tools.TableOpsTool;
import net.yacy.ai.tools.UnitConverterTool;
import net.yacy.ai.tools.WebFetchTool;
import net.yacy.ai.tools.WikipediaLinkCreatorTool;
import net.yacy.search.Switchboard;
/**
* Central registry and dispatch utility for all built-in YaCy LLM tools.
* <p>
* Responsibilities:
* <ul>
* <li>Expose tool JSON schemas into outbound chat-completion requests.</li>
* <li>Resolve tool names to handlers.</li>
* <li>Execute tool calls by name with raw JSON argument text.</li>
* </ul>
*/
public final class ToolProvider {
private static final String CONFIG_PREFIX = "ai.tools.";
private static final String DESCRIPTION_SUFFIX = ".description";
private static final String MAX_CALLS_SUFFIX = ".maxCallsPerTurn";
/**
* Tool handlers for different skill groups
*/
private static final List<ToolHandler> TOOLS_BASIC = Arrays.asList(
new DateTimeTool(),
new DateMathTool(),
new CalculatorTool(),
new NumberParserTool(),
new UnitConverterTool(),
new HttpJsonTool(),
new TableOpsTool(),
new SelfReflectTool(),
new ChitChatTool()
);
private static final List<ToolHandler> TOOLS_VISUALIZATION = Arrays.asList(
new PromptToMermaidTool(),
new Mermaid2ASCIITool()
);
private static final List<ToolHandler> TOOLS_RETRIEVAL = Arrays.asList(
new WikipediaLinkCreatorTool(),
new WebFetchTool()
);
/**
* Ordered list of built-in tool handlers. Order is preserved when exposing
* definitions to providers.
*/
private static final List<ToolHandler> TOOLS = new ArrayList<>();
static {
TOOLS.addAll(TOOLS_BASIC);
TOOLS.addAll(TOOLS_VISUALIZATION);
TOOLS.addAll(TOOLS_RETRIEVAL);
}
/**
* Lookup table for fast runtime dispatch by function/tool name.
*/
private static final Map<String, ToolHandler> TOOL_BY_NAME = buildToolIndex(TOOLS);
public static final class ToolConfig {
public final String name;
public final String description;
public final int maxCallsPerTurn;
public final int defaultMaxCallsPerTurn;
public final boolean enabled;
private ToolConfig(final String name, final String description, final int maxCallsPerTurn, final int defaultMaxCallsPerTurn) {
this.name = name;
this.description = description;
this.maxCallsPerTurn = Math.max(0, maxCallsPerTurn);
this.defaultMaxCallsPerTurn = Math.max(0, defaultMaxCallsPerTurn);
this.enabled = this.maxCallsPerTurn > 0;
}
}
/**
* Utility class; not instantiable.
*/
private ToolProvider() {}
/**
* Ensures a chat request body contains all available tool definitions and a
* default tool selection mode.
* <p>
* Existing tool definitions are kept; missing ones are appended.
*
* @param body request body to mutate
*/
public static void ensureTools(JSONObject body) {
if (body == null) return;
try {
// Create "tools" array lazily when caller did not provide one.
JSONArray tools = body.optJSONArray("tools");
if (tools == null) {
tools = new JSONArray();
body.put("tools", tools);
}
// Merge registry definitions without duplicating by tool name.
for (ToolHandler tool : TOOLS) {
JSONObject definition = configuredDefinition(tool);
if (definition == null) continue;
addToolDefinitionIfMissing(tools, definition);
}
// Providers usually expect explicit tool-choice mode.
if (!body.has("tool_choice")) {
body.put("tool_choice", "auto");
}
} catch (JSONException e) {
// keep body unchanged if tool schema cannot be injected
}
}
/**
* Dispatches one tool invocation by name.
*
* @param name tool/function name
* @param arguments raw JSON arguments string
* @return tool execution result as JSON string (or error JSON)
*/
public static String executeTool(String name, String arguments) {
if (name == null) return errorJson("Invalid tool call");
ToolHandler tool = TOOL_BY_NAME.get(name);
if (tool == null) return errorJson("Unknown tool: " + name);
if (maxCallsPerTurn(name) <= 0) return errorJson("Tool disabled: " + name);
// Tool implementations are responsible for argument parsing/validation.
return tool.execute(arguments);
}
/**
* Returns the configured maximum number of calls for a tool within one tool
* turn lifecycle.
*
* @param name tool/function name
* @return maximum call count, defaults to 1 for unknown/invalid values
*/
public static int maxCallsPerTurn(String name) {
if (name == null) return 1;
ToolHandler tool = TOOL_BY_NAME.get(name);
if (tool == null) return 1;
int max = configuredMaxCalls(name, tool.maxCallsPerTurn());
return Math.max(0, max);
}
public static List<ToolConfig> listTools() {
return listToolConfigs(TOOLS);
}
public static List<ToolConfig> listBasicTools() {
return listToolConfigs(TOOLS_BASIC);
}
public static List<ToolConfig> listVisualizationTools() {
return listToolConfigs(TOOLS_VISUALIZATION);
}
public static List<ToolConfig> listRetrievalTools() {
return listToolConfigs(TOOLS_RETRIEVAL);
}
private static List<ToolConfig> listToolConfigs(final List<ToolHandler> source) {
final List<ToolConfig> configuredTools = new ArrayList<>();
for (ToolHandler tool : source) {
if (tool == null) continue;
try {
final JSONObject definition = tool.definition();
final String name = extractToolName(definition);
if (name == null || name.isEmpty()) continue;
final JSONObject fn = definition.optJSONObject("function");
final String defaultDescription = fn == null ? "" : fn.optString("description", "");
final int defaultMaxCalls = Math.max(0, tool.maxCallsPerTurn());
configuredTools.add(new ToolConfig(
name,
configuredDescription(name, defaultDescription),
configuredMaxCalls(name, defaultMaxCalls),
defaultMaxCalls));
} catch (JSONException e) {
// skip invalid definitions
}
}
return Collections.unmodifiableList(configuredTools);
}
/**
* Builds an immutable map from tool name to handler.
* Invalid or unnamed definitions are skipped.
*
* @param tools source handlers
* @return unmodifiable name-indexed map
*/
private static Map<String, ToolHandler> buildToolIndex(List<ToolHandler> tools) {
Map<String, ToolHandler> byName = new LinkedHashMap<>();
for (ToolHandler tool : tools) {
if (tool == null) continue;
try {
String name = extractToolName(tool.definition());
if (name == null || name.isEmpty()) continue;
byName.put(name, tool);
} catch (JSONException e) {
// skip invalid tool definition
}
}
return Collections.unmodifiableMap(byName);
}
/**
* Appends a tool definition if no existing tool with the same function name
* is already present.
*
* @param tools destination tool array in request body
* @param toolDefinition candidate definition
*/
private static void addToolDefinitionIfMissing(JSONArray tools, JSONObject toolDefinition) {
if (tools == null || toolDefinition == null) return;
try {
String name = extractToolName(toolDefinition);
if (name == null || name.isEmpty()) return;
// Dedupe by logical function name (not by full JSON string equality).
for (int i = 0; i < tools.length(); i++) {
JSONObject existing = tools.optJSONObject(i);
if (existing == null) continue;
String existingName = extractToolName(existing);
if (name.equals(existingName)) return;
}
tools.put(toolDefinition);
} catch (JSONException e) {
// ignore malformed tool schema
}
}
/**
* Extracts the function name from a tool definition object.
*
* @param toolDefinition MCP/OpenAI-style tool definition
* @return trimmed function name or {@code null}
* @throws JSONException if JSON access fails unexpectedly
*/
private static String extractToolName(JSONObject toolDefinition) throws JSONException {
if (toolDefinition == null) return null;
JSONObject fn = toolDefinition.optJSONObject("function");
if (fn == null) return null;
String name = fn.optString("name", "");
return name == null ? null : name.trim();
}
private static JSONObject configuredDefinition(final ToolHandler tool) {
if (tool == null) return null;
try {
final JSONObject definition = tool.definition();
final String name = extractToolName(definition);
if (name == null || name.isEmpty()) return definition;
if (configuredMaxCalls(name, tool.maxCallsPerTurn()) <= 0) return null;
final JSONObject fn = definition.optJSONObject("function");
if (fn != null) {
final String defaultDescription = fn.optString("description", "");
fn.put("description", configuredDescription(name, defaultDescription));
}
return definition;
} catch (JSONException e) {
return null;
}
}
private static String configuredDescription(final String toolName, final String defaultDescription) {
if (toolName == null || toolName.isEmpty()) return defaultDescription == null ? "" : defaultDescription;
final Switchboard sb = Switchboard.getSwitchboard();
if (sb == null) return defaultDescription == null ? "" : defaultDescription;
return sb.getConfig(CONFIG_PREFIX + toolName + DESCRIPTION_SUFFIX, defaultDescription == null ? "" : defaultDescription);
}
private static int configuredMaxCalls(final String toolName, final int defaultMaxCalls) {
if (toolName == null || toolName.isEmpty()) return Math.max(0, defaultMaxCalls);
final Switchboard sb = Switchboard.getSwitchboard();
if (sb == null) return Math.max(0, defaultMaxCalls);
final int configured = sb.getConfigInt(CONFIG_PREFIX + toolName + MAX_CALLS_SUFFIX, defaultMaxCalls);
return Math.max(0, configured);
}
/**
* Builds a minimal error JSON string while avoiding propagation of secondary
* JSON construction failures.
*
* @param message human-readable error message
* @return serialized JSON error object
*/
private static String errorJson(String message) {
try {
JSONObject err = new JSONObject(true);
err.put("error", message == null ? "error" : message);
return err.toString();
} catch (JSONException e) {
return "{\"error\":\"" + (message == null ? "error" : message.replace("\"", "'")) + "\"}";
}
}
}