diff --git a/docs/agents/mcp_tools.md b/docs/agents/mcp_tools.md index f066c6e5294..4d4fea74fc1 100644 --- a/docs/agents/mcp_tools.md +++ b/docs/agents/mcp_tools.md @@ -218,6 +218,24 @@ The runtime reads `_meta` from `pixel_mcp.json` and `py_mcp.json` as written, so See [run lifecycle](agent_runs.md) for approval states and [the harness](semoss_harness.md#tools-and-execution-policy) for the built-in tool groups. +## Pre-chat tool + +One toolbox tool can run once, when a room is set up and before its first message. Its result goes in front of the text of that first user message: the model reads it there on this and every later turn, and the UI shows only what the user typed (the message's `uiText`). Nothing else is added to the room's history. A typical choice is a discovery tool such as ECU's `GetRoomResources`, so the model starts with the room's documents, skills and toolboxes in context instead of spending its first step calling the tool. + +| Surface | Configured by | +| --- | --- | +| `RunAgent` (native `semoss` harness) | Deployment properties, only when `AGENT_DEFAULT_TOOLS_MCP_ID` replaces the default tools: `AGENT_DEFAULT_TOOLS_PRE_CHAT_TOOL` (tool name in that toolbox), optional `AGENT_DEFAULT_TOOLS_PRE_CHAT_ARGS` (JSON object) and `AGENT_DEFAULT_TOOLS_PRE_CHAT_MAX_CHARS`. Skipped when the agent turns default tools off or lists the tool in `tool_policy.default_tools.disabled`, and for PPTX workflow runs. | +| `AskPlayground` | `playground.preChatTool` in the active theme map: `{"tool": "GetRoomResources", "toolboxId": "", "args": {}, "maxChars": 60000}`. Without `toolboxId`, the tool is looked up in `playground.defaultTools` (the toolboxes the UI attaches to every new room), in order. | + +Both use [PreChatContext](../../src/prerna/reactor/agent/runtime/PreChatContext.java): + +- The tool runs through `MCPUtility.executeTool`, so the user's access to the toolbox is checked and `SMSS_FUNCTION_NAME` applies. +- A tool that is missing, `disabled` or `ask` is skipped; nothing is shown to approve before the first message. +- The insight must be unbound or bound to the room (chat mode binds it with `SetRoomForInsight`); an unbound insight is bound to the room first. +- The result is cut to `maxChars` (default 60,000) and wrapped in `` with a note telling the model it need not call the tool again unless something changed. +- Any failure is logged and skipped, and the chat continues as it would without the setting. The tool stays available, so the model can still call it itself. +- It runs once per room, only while the room has no messages. Files added later are not reflected until the model calls the tool again. + ## Pitfalls - Use lowercase execution values in hand-written JSON. The hide filter is case-sensitive. @@ -238,4 +256,5 @@ See [run lifecycle](agent_runs.md) for approval states and [the harness](semoss_ | Room tool assembly and alias lookup | [Room](../../src/prerna/engine/impl/model/Room.java) | | Deferred tools | [DeferredAgentTools](../../src/prerna/reactor/agent/run/DeferredAgentTools.java) | | Platform tool defaults | [PlatformAgentTools](../../src/prerna/reactor/agent/runtime/PlatformAgentTools.java) | +| Pre-chat tool | [PreChatContext](../../src/prerna/reactor/agent/runtime/PreChatContext.java), [PlaygroundThemeUtils](../../src/prerna/theme/PlaygroundThemeUtils.java) | | Approval pauses | [HarnessToolExecutor](../../src/prerna/reactor/agent/runtime/HarnessToolExecutor.java), [AgentToolDecisionHandler](../../src/prerna/reactor/agent/mcp/AgentToolDecisionHandler.java) | diff --git a/src/prerna/playground/reactors/AskPlaygroundReactor.java b/src/prerna/playground/reactors/AskPlaygroundReactor.java index 6de8164576b..b6bdd9835a1 100644 --- a/src/prerna/playground/reactors/AskPlaygroundReactor.java +++ b/src/prerna/playground/reactors/AskPlaygroundReactor.java @@ -29,6 +29,7 @@ import prerna.playground.PlaygroundUtils; import prerna.reactor.ReactorDefinition; +import prerna.reactor.agent.runtime.PreChatContext; import prerna.reactor.model.AskRoomReactor; import prerna.theme.PlaygroundThemeUtils; @@ -44,6 +45,11 @@ protected boolean shouldHideSystemMessages() { return PlaygroundThemeUtils.hidePlaygroundSystemMessages(); } + @Override + protected PreChatContext.Spec getPreChatTool() { + return PlaygroundThemeUtils.getPlaygroundPreChatTool(); + } + @Override public ReactorDefinition getReactorDefinition() { return super.getReactorDefinition().description( diff --git a/src/prerna/reactor/agent/runtime/PlatformAgentTools.java b/src/prerna/reactor/agent/runtime/PlatformAgentTools.java index 46717c2117c..13fcff4a6d0 100644 --- a/src/prerna/reactor/agent/runtime/PlatformAgentTools.java +++ b/src/prerna/reactor/agent/runtime/PlatformAgentTools.java @@ -39,6 +39,7 @@ import prerna.engine.api.IEngine; import prerna.engine.api.ToolExecutionResult; import prerna.reactor.agent.AgentRunContext; +import prerna.reactor.agent.config.AgentConfig; import prerna.reactor.agent.mcp.MCPUtility; import prerna.reactor.agent.mcp.MCPUtility.MCPExecution; import prerna.reactor.agent.mcp.RunMCPToolReactor; @@ -55,6 +56,9 @@ public final class PlatformAgentTools { private static final String PARAM_TOOLS = "tools"; private static final String PROP_DEFAULT_TOOLS_MCP_ID = "AGENT_DEFAULT_TOOLS_MCP_ID"; private static final String PROP_DEFAULT_TOOLS_MCP_PROJECT_ID = "AGENT_DEFAULT_TOOLS_MCP_PROJECT_ID"; + private static final String PROP_PRE_CHAT_TOOL = "AGENT_DEFAULT_TOOLS_PRE_CHAT_TOOL"; + private static final String PROP_PRE_CHAT_ARGS = "AGENT_DEFAULT_TOOLS_PRE_CHAT_ARGS"; + private static final String PROP_PRE_CHAT_MAX_CHARS = "AGENT_DEFAULT_TOOLS_PRE_CHAT_MAX_CHARS"; private static final Set DEFERRED_DEFAULT_TOOLS = Set.of("InspectPptx", "MultiEdit"); private static final Map PLATFORM_TOOLS = @@ -90,6 +94,29 @@ static List> resolveDefaultTools(Map paramMa return resolved; } + /** + * The tool to run before a room's first message, from + * {@code AGENT_DEFAULT_TOOLS_PRE_CHAT_TOOL}. Only set when the deployment + * replaces the default tools ({@code AGENT_DEFAULT_TOOLS_MCP_ID}); the tool is + * looked up in that toolbox. Returns {@code null} when unset, or when this + * agent turns default tools off or disables that tool. + */ + static PreChatContext.Spec preChatSpec(AgentConfig agentConfig) { + String mcpId = getDefaultToolsMcpId(); + String tool = Utility.getDIHelperProperty(PROP_PRE_CHAT_TOOL); + tool = tool != null ? tool.trim() : null; + if (mcpId == null || tool == null || tool.isEmpty()) { + return null; + } + if (agentConfig != null + && (!agentConfig.useDefaultAgentTools() || agentConfig.getDisabledDefaultTools().contains(tool))) { + return null; + } + return PreChatContext.Spec.of(mcpId, tool, + PreChatContext.parseArgs(Utility.getDIHelperProperty(PROP_PRE_CHAT_ARGS)), + PreChatContext.parseInt(Utility.getDIHelperProperty(PROP_PRE_CHAT_MAX_CHARS))); + } + static ToolExecutionResult policyDenialResult(String toolName, AgentRunContext ctx) { if (toolName == null || toolName.trim().isEmpty() || ctx == null) { return null; @@ -169,7 +196,7 @@ private static boolean useDefaultAgentTools(Map paramMap) { return !("false".equals(normalized) || "0".equals(normalized) || "no".equals(normalized)); } - private static String getDefaultToolsMcpId() { + static String getDefaultToolsMcpId() { String id = Utility.getDIHelperProperty(PROP_DEFAULT_TOOLS_MCP_ID); if (id == null || id.trim().isEmpty()) { id = Utility.getDIHelperProperty(PROP_DEFAULT_TOOLS_MCP_PROJECT_ID); @@ -206,7 +233,7 @@ private static List> getMcpToolDefinitions(String mcpId) { return tools; } - private static JSONObject findMcpTool(String mcpId, String toolName) { + static JSONObject findMcpTool(String mcpId, String toolName) { JSONObject toolMap = getMcpToolsJson(mcpId); JSONArray arr = toolMap != null ? toolMap.optJSONArray(PARAM_TOOLS) : null; if (arr == null) { diff --git a/src/prerna/reactor/agent/runtime/PreChatContext.java b/src/prerna/reactor/agent/runtime/PreChatContext.java new file mode 100644 index 00000000000..3e663a2431a --- /dev/null +++ b/src/prerna/reactor/agent/runtime/PreChatContext.java @@ -0,0 +1,283 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program 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 General Public License for more details. + *******************************************************************************/ +package prerna.reactor.agent.runtime; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.JSONObject; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import prerna.engine.impl.model.Room; +import prerna.om.Insight; +import prerna.reactor.agent.mcp.MCPUtility; +import prerna.reactor.agent.mcp.MCPUtility.MCPExecution; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Runs one toolbox tool when a room is set up, before its first message, and + * returns the result as a note that goes in front of that message's text. The + * model reads it; the UI shows only what the user typed ({@code uiText}). + *

+ * Two surfaces use it: + *

    + *
  • the {@code semoss} agent harness, when the deployment replaces the default + * agent tools ({@code AGENT_DEFAULT_TOOLS_MCP_ID}) and names a pre-chat tool + * ({@code AGENT_DEFAULT_TOOLS_PRE_CHAT_TOOL});
  • + *
  • Playground chat, from {@code playground.preChatTool} in the active theme + * map.
  • + *
+ * The tool runs through {@link MCPUtility#executeTool}, so the caller's access to + * the toolbox is checked and the toolbox's own {@code SMSS_FUNCTION_NAME} mapping + * applies. A tool that is missing, {@code disabled} or {@code ask} is skipped: + * nothing is shown to approve before the first message. Every failure is logged + * and skipped, so the chat carries on as it would without the feature, and the + * model can still call the tool itself. + */ +public final class PreChatContext { + + private static final Logger logger = LogManager.getLogger(PreChatContext.class); + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + /** Result characters kept when the configuration does not set a limit. */ + public static final int DEFAULT_MAX_CHARS = 60_000; + + private PreChatContext() { + } + + /** + * Which tool to run and with what arguments. + * + * @param toolboxIds engine or project ids of the toolboxes (MCPs) to look in, + * in order; the first one that provides {@code tool} runs it + * @param tool tool name as listed by the toolbox + * @param args tool arguments, never {@code null} + * @param maxChars result characters kept in the note + */ + public record Spec(List toolboxIds, String tool, Map args, int maxChars) { + + public Spec { + toolboxIds = List.copyOf(toolboxIds); + args = args == null ? Collections.emptyMap() : Collections.unmodifiableMap(new LinkedHashMap<>(args)); + maxChars = maxChars > 0 ? maxChars : DEFAULT_MAX_CHARS; + } + + /** + * Builds a spec for one toolbox, or returns {@code null} when the toolbox or + * tool is blank. + */ + public static Spec of(String toolboxId, String tool, Map args, Integer maxChars) { + return anyOf(toolboxId == null ? List.of() : java.util.Arrays.asList(toolboxId), tool, args, maxChars); + } + + /** + * Builds a spec that looks for the tool in each toolbox in turn, or returns + * {@code null} when no toolbox id is given or the tool is blank. Blank and + * repeated ids are dropped. + */ + public static Spec anyOf(List toolboxIds, String tool, Map args, Integer maxChars) { + tool = trimToNull(tool); + java.util.Set ids = new java.util.LinkedHashSet<>(); + if (toolboxIds != null) { + for (String id : toolboxIds) { + id = trimToNull(id); + if (id != null) { + ids.add(id); + } + } + } + if (ids.isEmpty() || tool == null) { + return null; + } + return new Spec(new ArrayList<>(ids), tool, args, maxChars == null ? 0 : maxChars); + } + } + + /** + * Runs the tool and returns its result as a system-prompt note when the room + * has no messages yet. Used by playground chat, where the result is injected + * into the first message's system prompt rather than stored as a hidden pair. + * + * @param room room about to receive its first message + * @param insight caller insight; must be unbound or bound to {@code room} + * @param spec tool to run; {@code null} returns {@code null} + * @return formatted note to append to the system prompt, or {@code null} to skip + */ + public static String runForSystemPrompt(Room room, Insight insight, Spec spec) { + if (spec == null || room == null || insight == null || !room.getMessages().isEmpty()) { + return null; + } + try { + return runTool(room, insight, spec); + } catch (Exception e) { + logger.warn("PreChatContext: skipped {} for room={}: {}", spec.tool(), room.getId(), e.getMessage(), e); + return null; + } + } + + /** + * Shared tool-execution core: binds the insight to the room if needed, finds + * the tool in the configured toolboxes, runs it, and returns the formatted note + * string, or {@code null} when the tool should be skipped. + */ + private static String runTool(Room room, Insight insight, Spec spec) { + String boundRoomId = trimToNull(insight.getRoomId()); + if (boundRoomId == null) { + insight.setRoomForInsight(room); + } else if (!boundRoomId.equals(room.getId())) { + logger.warn("PreChatContext: skipped {} for room={}: insight is bound to room={}", spec.tool(), + room.getId(), boundRoomId); + return null; + } + + String toolboxId = null; + JSONObject tool = null; + for (String candidate : spec.toolboxIds()) { + try { + tool = PlatformAgentTools.findMcpTool(candidate, spec.tool()); + } catch (Exception e) { + logger.warn("PreChatContext: cannot read toolbox {}: {}", candidate, e.getMessage()); + } + if (tool != null) { + toolboxId = candidate; + break; + } + } + if (tool == null) { + logger.warn("PreChatContext: skipped room={}: tool {} is missing or disabled in toolboxes {}", + room.getId(), spec.tool(), spec.toolboxIds()); + return null; + } + JSONObject meta = tool.optJSONObject("_meta"); + if (meta != null && MCPExecution.ASK.getValue().equalsIgnoreCase(meta.optString(MCPUtility.SMSS_MCP_EXECUTION))) { + logger.warn("PreChatContext: skipped room={}: tool {} needs user approval", room.getId(), spec.tool()); + return null; + } + String functionName = meta != null ? meta.optString(MCPUtility.SMSS_FUNCTION_NAME, spec.tool()) + : spec.tool(); + + long start = System.currentTimeMillis(); + Object raw = MCPUtility.executeTool(toolboxId, functionName, new LinkedHashMap<>(spec.args()), insight); + String output = stringify(raw); + if (output == null || output.isBlank() || output.startsWith("Error:")) { + logger.warn("PreChatContext: skipped room={}: tool {} returned no usable result", room.getId(), + spec.tool()); + return null; + } + logger.info("PreChatContext: room={} toolbox={} tool={} chars={} ms={}", room.getId(), toolboxId, + spec.tool(), output.length(), System.currentTimeMillis() - start); + return note(spec.tool(), output, spec.maxChars()); + } + + /** + * Wraps a tool result for the model, truncating it to {@code maxChars}. + */ + static String note(String toolName, String output, int maxChars) { + boolean truncated = output.length() > maxChars; + String body = truncated ? output.substring(0, maxChars) : output; + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(body); + if (truncated) { + sb.append("\n[truncated: ").append(output.length() - maxChars).append(" more characters]"); + } + sb.append("\n\n\n"); + sb.append(""" + The platform ran %s when this chat started, before the user's first message; its result is above. \ + Treat it as if you had called the tool yourself. Don't call %s again unless the user adds or \ + mentions new material, asks what is available, or you need something this result doesn't cover.\ + """.formatted(toolName, toolName)); + if (truncated) { + sb.append(" The result was truncated, so call ").append(toolName) + .append(" if you need the part that was cut."); + } + sb.append(" Do not mention this note to the user."); + return sb.toString(); + } + + static String stringify(Object raw) { + if (raw instanceof NounMetadata noun) { + raw = noun.getValue(); + } + if (raw == null) { + return null; + } + if (raw instanceof String s) { + return s; + } + return GSON.toJson(raw); + } + + /** + * Parses a JSON object of tool arguments; blank or invalid input yields an + * empty map. + */ + static Map parseArgs(String json) { + json = trimToNull(json); + if (json == null) { + return Collections.emptyMap(); + } + try { + return new JSONObject(json).toMap(); + } catch (Exception e) { + logger.warn("PreChatContext: ignoring invalid tool arguments: {}", e.getMessage()); + return Collections.emptyMap(); + } + } + + static Integer parseInt(String value) { + value = trimToNull(value); + if (value == null) { + return null; + } + try { + return Integer.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } + + private static String trimToNull(String s) { + if (s == null) { + return null; + } + s = s.trim(); + return s.isEmpty() ? null : s; + } + + private static String xmlEscape(String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } +} diff --git a/src/prerna/reactor/agent/runtime/SemossAgentHarness.java b/src/prerna/reactor/agent/runtime/SemossAgentHarness.java index d5b9bec0e7f..fcbbde6c79c 100644 --- a/src/prerna/reactor/agent/runtime/SemossAgentHarness.java +++ b/src/prerna/reactor/agent/runtime/SemossAgentHarness.java @@ -359,13 +359,24 @@ public AgentHarnessResult execute(AgentRunContext ctx) throws Exception { if (compactionOutcome == AutoCompactionOutcome.CONTEXT_WINDOW_UNAVAILABLE) { autoCompactionContextWarningLogged = true; } + // A deployment that replaces the default tools can name one to run before the + // room's first message; its result is injected as a hidden prefix in the first + // message text so the model sees it without a separate message pair. + String preChatNote = (!focusedEdit && !agentConfig.hasPptxWorkflow() && room.getMessages().isEmpty()) + ? PreChatContext.runForSystemPrompt(room, ctx.getInsight(), + PlatformAgentTools.preChatSpec(agentConfig)) + : null; // Compaction messages are implementation history, not messages produced by this // run. Start run tagging after any automatic compaction messages. runMessageStartIndex = room.getMessages().size(); String priorRequests = focusedEdit ? PptxEditContext.priorRequests(room.getMessages()) : ""; + String modelText = ctx.getInput() + priorRequests + "\n\n" + state.runtimeContext(); + if (preChatNote != null) { + modelText = preChatNote + "\n\n" + modelText; + } InputMessage firstMsg = InputMessage.builder(room).withSystemPrompt(systemPrompt) - .withText(ctx.getInput() + priorRequests + "\n\n" + state.runtimeContext(), ctx.getUserText()) + .withText(modelText, ctx.getUserText()) .withMediaInputs(ctx.getMediaInputPaths(), room).withMediaUrls(ctx.getMediaUrls()) .withModelType(ctx.getModelEngine().getModelType()).withParamMap(paramMap).build(); tagAgentRun(firstMsg, ctx.getRunId(), RUN_ROLE_INPUT); diff --git a/src/prerna/reactor/model/AskRoomReactor.java b/src/prerna/reactor/model/AskRoomReactor.java index e2413f01521..0613c7af06d 100644 --- a/src/prerna/reactor/model/AskRoomReactor.java +++ b/src/prerna/reactor/model/AskRoomReactor.java @@ -47,6 +47,7 @@ import prerna.engine.impl.model.message.ResponseMessage; import prerna.reactor.AbstractReactor; import prerna.reactor.ReactorDefinition; +import prerna.reactor.agent.runtime.PreChatContext; import prerna.sablecc2.om.PixelDataType; import prerna.sablecc2.om.ReactorKeysEnum; import prerna.sablecc2.om.nounmeta.NounMetadata; @@ -102,12 +103,24 @@ public NounMetadata execute() { if (projectIdOverride != null) { room.setProjectId(projectIdOverride); } + // Run the pre-chat tool and prepend its result to the first user message as + // a hidden prefix: the model sees it, the UI shows only the user's question. + // Nothing is added to room history. + String preChatNote = responseParts == null + ? PreChatContext.runForSystemPrompt(room, this.insight, getPreChatTool()) + : null; String givenSystemPrompt = room.getSystemPromptForModel(); List copiedMedia = RoomUtils.copyFilesToRoomFolder(inputMedia, room, insight); - InputMessage inputMessage = InputMessage.builder(room).withSystemPrompt(givenSystemPrompt) - .withMediaInputs(copiedMedia, room).withMediaUrls(inputMediaURLs).withText(question) - .withModelType(modelEngine.getModelType()).withParamMap(paramMap).build(); + InputMessage.Builder msgBuilder = InputMessage.builder(room).withSystemPrompt(givenSystemPrompt) + .withMediaInputs(copiedMedia, room).withMediaUrls(inputMediaURLs) + .withModelType(modelEngine.getModelType()).withParamMap(paramMap); + if (preChatNote != null) { + msgBuilder.withText(preChatNote + "\n\n" + question, question); + } else { + msgBuilder.withText(question); + } + InputMessage inputMessage = msgBuilder.build(); List extraMessages = new ArrayList<>(); ResponseMessage responseMessage; @@ -151,6 +164,14 @@ protected String getProjectIdOverride() { return null; } + /** + * Returns the tool to run once, before a new room's first message, or + * {@code null} for none. Its result is stored as a hidden note for the model. + */ + protected PreChatContext.Spec getPreChatTool() { + return null; + } + /** * Returns whether system content should be removed from the client response. */ diff --git a/src/prerna/theme/PlaygroundThemeUtils.java b/src/prerna/theme/PlaygroundThemeUtils.java index b8d30459c11..359548369c8 100644 --- a/src/prerna/theme/PlaygroundThemeUtils.java +++ b/src/prerna/theme/PlaygroundThemeUtils.java @@ -27,19 +27,23 @@ *******************************************************************************/ package prerna.theme; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.json.JSONObject; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import prerna.engine.api.IRDBMSEngine; +import prerna.reactor.agent.runtime.PreChatContext; import prerna.util.Constants; import prerna.util.SystemEngineRegistry; @@ -53,6 +57,7 @@ public class PlaygroundThemeUtils extends AbstractThemeUtils { private static volatile String cachedGlobalSystemPrompt = null; private static volatile Map cachedSystemPromptVars = null; private static volatile boolean cachedHideSystemMessages = false; + private static volatile PreChatContext.Spec cachedPreChatTool = null; private static volatile boolean cacheInitialized = false; private PlaygroundThemeUtils() { @@ -99,6 +104,32 @@ public static boolean hidePlaygroundSystemMessages() { return cachedHideSystemMessages; } + /** + * Returns {@code playground.preChatTool} from the currently active theme, or + * {@code null} when it is not set or names no toolbox or tool. Playground runs + * this tool once, before a room's first message, and gives its result to the + * model as a hidden note. + *

+ * Expected JSON shape ({@code args} and {@code maxChars} are optional): + * + *

+	 * {
+	 *   "playground": {
+	 *     "preChatTool": {
+	 *       "toolboxId": "<engine or project id; defaults to defaultTools>",
+	 *       "tool": "GetRoomResources",
+	 *       "args": {},
+	 *       "maxChars": 60000
+	 *     }
+	 *   }
+	 * }
+	 * 
+ */ + public static PreChatContext.Spec getPlaygroundPreChatTool() { + ensureCacheLoaded(); + return cachedPreChatTool; + } + /** * Refreshes the in-memory cache from the active theme. */ @@ -114,6 +145,7 @@ private static void refreshCache() { cachedGlobalSystemPrompt = null; cachedSystemPromptVars = null; cachedHideSystemMessages = false; + cachedPreChatTool = null; cacheInitialized = false; } } @@ -129,6 +161,7 @@ private static void parseThemeMap(String themeMapJson) { cachedGlobalSystemPrompt = null; cachedSystemPromptVars = new LinkedHashMap<>(); cachedHideSystemMessages = false; + cachedPreChatTool = null; if (themeMapJson == null) { return; } @@ -143,6 +176,8 @@ private static void parseThemeMap(String themeMapJson) { cachedHideSystemMessages = getBooleanValue(playground, "hide_system_messages", getBooleanValue(playground, "hideSystemMessages", false)); + cachedPreChatTool = parsePreChatTool(playground.get("preChatTool"), playground.get("defaultTools")); + JsonElement globalSystemPromptElem = playground.get("globalSystemPrompt"); if (globalSystemPromptElem != null && globalSystemPromptElem.isJsonPrimitive()) { cachedGlobalSystemPrompt = StringUtils.trimToNull(globalSystemPromptElem.getAsString()); @@ -169,6 +204,56 @@ private static void parseThemeMap(String themeMapJson) { } } + /** + * Reads a {@code preChatTool} object. Without its own {@code toolboxId}, the + * tool is looked up in the theme's {@code defaultTools} (the toolboxes the UI + * attaches to every new room), in order. Anything with no tool name or no + * toolbox to look in yields {@code null}. + */ + static PreChatContext.Spec parsePreChatTool(JsonElement elem, JsonElement defaultTools) { + if (elem == null || !elem.isJsonObject()) { + return null; + } + JsonObject obj = elem.getAsJsonObject(); + List toolboxIds = new ArrayList<>(); + String toolboxId = getStringValue(obj, "toolboxId"); + if (toolboxId != null) { + toolboxIds.add(toolboxId); + } else if (defaultTools != null && defaultTools.isJsonArray()) { + for (JsonElement tool : defaultTools.getAsJsonArray()) { + if (tool != null && tool.isJsonObject()) { + String id = getStringValue(tool.getAsJsonObject(), "id"); + if (id != null) { + toolboxIds.add(id); + } + } + } + } + Map args = null; + JsonElement argsElem = obj.get("args"); + if (argsElem != null && argsElem.isJsonObject()) { + args = new JSONObject(argsElem.toString()).toMap(); + } + Integer maxChars = null; + JsonElement maxElem = obj.get("maxChars"); + try { + if (maxElem != null && maxElem.isJsonPrimitive() && maxElem.getAsJsonPrimitive().isNumber()) { + maxChars = maxElem.getAsInt(); + } + } catch (NumberFormatException e) { + // out of range: use the default + } + return PreChatContext.Spec.anyOf(toolboxIds, getStringValue(obj, "tool"), args, maxChars); + } + + private static String getStringValue(JsonObject obj, String key) { + JsonElement elem = obj.get(key); + if (elem == null || !elem.isJsonPrimitive()) { + return null; + } + return StringUtils.trimToNull(elem.getAsString()); + } + private static boolean getBooleanValue(JsonObject obj, String key, boolean defaultValue) { JsonElement elem = obj.get(key); if (elem == null || elem.isJsonNull() || !elem.isJsonPrimitive()) { diff --git a/test/prerna/reactor/agent/runtime/PreChatContextUnitTests.java b/test/prerna/reactor/agent/runtime/PreChatContextUnitTests.java new file mode 100644 index 00000000000..8ba12c0d017 --- /dev/null +++ b/test/prerna/reactor/agent/runtime/PreChatContextUnitTests.java @@ -0,0 +1,89 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program 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 General Public License for more details. + *******************************************************************************/ +package prerna.reactor.agent.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +class PreChatContextUnitTests { + + @Test + void specNeedsToolboxAndTool() { + assertNull(PreChatContext.Spec.of(null, "GetRoomResources", null, null)); + assertNull(PreChatContext.Spec.of("ecu", " ", null, null)); + PreChatContext.Spec spec = PreChatContext.Spec.of(" ecu ", " GetRoomResources ", null, null); + assertEquals(List.of("ecu"), spec.toolboxIds()); + assertEquals("GetRoomResources", spec.tool()); + assertTrue(spec.args().isEmpty()); + assertEquals(PreChatContext.DEFAULT_MAX_CHARS, spec.maxChars()); + assertEquals(PreChatContext.DEFAULT_MAX_CHARS, PreChatContext.Spec.of("e", "t", null, -5).maxChars()); + assertEquals(10, PreChatContext.Spec.of("e", "t", Map.of("a", 1), 10).maxChars()); + assertEquals(List.of("a", "b"), + PreChatContext.Spec.anyOf(java.util.Arrays.asList(" a ", null, "", "b", "a"), "t", null, null).toolboxIds()); + assertNull(PreChatContext.Spec.anyOf(List.of(), "t", null, null)); + } + + @Test + void parsesArgsAndLimits() { + assertEquals(Map.of("limit", 5), PreChatContext.parseArgs("{\"limit\": 5}")); + assertTrue(PreChatContext.parseArgs("not json").isEmpty()); + assertTrue(PreChatContext.parseArgs(" ").isEmpty()); + assertEquals(42, PreChatContext.parseInt(" 42 ")); + assertNull(PreChatContext.parseInt("lots")); + } + + @Test + void noteWrapsAndTruncates() { + String whole = PreChatContext.note("GetRoomResources", "{\"room_files\":[]}", 100); + assertTrue(whole.startsWith("\n{\"room_files\":[]}\n")); + assertTrue(whole.contains("Don't call GetRoomResources again")); + assertFalse(whole.contains("truncated")); + + String cut = PreChatContext.note("T", "abcdefghij", 4); + assertTrue(cut.startsWith("\nabcd\n[truncated: 6 more characters]")); + assertTrue(cut.contains("The result was truncated")); + } + + @Test + void stringifiesToolOutput() { + assertEquals("text", PreChatContext.stringify("text")); + assertEquals("ok", PreChatContext.stringify(new NounMetadata("ok", PixelDataType.CONST_STRING))); + assertEquals("[1,2]", PreChatContext.stringify(List.of(1, 2))); + assertNull(PreChatContext.stringify(null)); + } +} diff --git a/test/prerna/theme/PlaygroundThemeUtilsUnitTests.java b/test/prerna/theme/PlaygroundThemeUtilsUnitTests.java new file mode 100644 index 00000000000..830f124f73c --- /dev/null +++ b/test/prerna/theme/PlaygroundThemeUtilsUnitTests.java @@ -0,0 +1,75 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program 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 General Public License for more details. + *******************************************************************************/ +package prerna.theme; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonParser; + +import prerna.reactor.agent.runtime.PreChatContext; + +class PlaygroundThemeUtilsUnitTests { + + @Test + void readsPreChatTool() { + PreChatContext.Spec spec = PlaygroundThemeUtils.parsePreChatTool(JsonParser.parseString( + "{\"toolboxId\":\"ecu\",\"tool\":\"GetRoomResources\",\"args\":{\"mode\":\"brief\"},\"maxChars\":500}"), + JsonParser.parseString("[{\"id\":\"other\"}]")); + assertEquals(List.of("ecu"), spec.toolboxIds()); + assertEquals("GetRoomResources", spec.tool()); + assertEquals(Map.of("mode", "brief"), spec.args()); + assertEquals(500, spec.maxChars()); + } + + @Test + void fallsBackToThemeDefaultTools() { + PreChatContext.Spec spec = PlaygroundThemeUtils.parsePreChatTool( + JsonParser.parseString("{\"tool\":\"GetRoomResources\"}"), + JsonParser.parseString( + "[{\"type\":\"PROJECT\",\"id\":\"ecu\",\"name\":\"ECU\"},{\"id\":\" \"},\"bad\",{\"id\":\"other\"}]")); + assertEquals(List.of("ecu", "other"), spec.toolboxIds()); + assertEquals("GetRoomResources", spec.tool()); + } + + @Test + void ignoresIncompleteOrMalformedPreChatTool() { + assertNull(PlaygroundThemeUtils.parsePreChatTool(null, null)); + assertNull(PlaygroundThemeUtils.parsePreChatTool(JsonParser.parseString("\"GetRoomResources\""), null)); + assertNull(PlaygroundThemeUtils.parsePreChatTool(JsonParser.parseString("{\"tool\":\"GetRoomResources\"}"), null)); + PreChatContext.Spec spec = PlaygroundThemeUtils.parsePreChatTool(JsonParser.parseString( + "{\"toolboxId\":\"ecu\",\"tool\":\"T\",\"args\":[1],\"maxChars\":\"big\"}"), null); + assertEquals(Map.of(), spec.args()); + assertEquals(PreChatContext.DEFAULT_MAX_CHARS, spec.maxChars()); + } +}