Add LLMSystemPrompt as a unified system-prompt builder for OllamaObject

Replaces ad-hoc system-prompt string construction with a structured
builder (Identity, Context, Capabilities, Behavior, Output), plus
OllamaObject#setSystemMessage/setSystemPrompt for replacing the
system message post-construction instead of only at build time.
This commit is contained in:
2026-07-19 21:47:18 +02:00
parent 6e31e31718
commit fc8a06bde5
4 changed files with 672 additions and 11 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ plugins {
id 'java-library'
}
version = '1.7'
version = '1.8.13'
dependencies {
implementation project(":Plugin-API")
@@ -0,0 +1,593 @@
package me.neurodock.core;
import com.sun.jdi.connect.spi.TransportService;
import me.neurodock.ollama.*;
import org.json.JSONObject;
import java.io.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
//# Identity
//You are [name], [role/purpose].
//
//# Context
//[Static facts it needs — what NeuroDock is, what world/game state means, etc.]
//
//# Capabilities / Tools
//[What tools it has access to and when to use them]
//
//# Behavior rules
//- [Constraints: tone, length, what not to do]
//- [Formatting requirements]
//
//# Output format
//[Exact schema if you need structured output — JSON shape, etc.]
/**
* Compiles a structured system prompt for an LLM out of discrete, reusable pieces —
* identity, static context, tool-usage capabilities, behavior rules, and output format —
* instead of hand-writing and concatenating prompt strings inline.
* <p>
* Each populated section is wrapped in its own XML-style tag
* ({@code <Identity>}, {@code <Context>}, {@code <Capabilities>}, {@code <Behavior>},
* {@code <Output>}) and only included in the final prompt if it was actually set — see
* {@link #generateSystemPrompt()}.
* <p>
* Instances are assembled via {@link #builder()}. Callers who need capability routing
* hints compiled from a live set of registered tools must call
* {@link #generateCapabilities(OllamaObject)} before {@link #generateSystemPrompt()};
* see that method's docs for why this is a separate step rather than being folded into
* construction.
*
* @see Builder
* @see Capabilities
*/
public class LLMSystemPrompt {
/**
* The persona/role block for this prompt, or {@code null} if not set. See {@link Identity}.
*/
private Identity identity;
/**
* The raw, pre-built {@code <Context>} body for this prompt (static facts the model
* needs), or {@code null} if not set. Typically produced via {@link Context#build()}.
*/
private String context;
/**
* Usage-hint definitions for registered tools, or {@code null} if this prompt doesn't
* compile a capabilities section. This holds the *rules* for what to say about each
* tool; the actual compiled text lives in {@link #compiledCapabilities} and is only
* populated once {@link #generateCapabilities(OllamaObject)} has been called.
*/
private Capabilities capabilities;
/**
* The compiled, ready-to-render {@code <Capabilities>} body, built by
* {@link #generateCapabilities(OllamaObject)} from {@link #capabilities} and a live
* set of registered tools. {@code null} until that method has been called at least
* once, or if {@link #capabilities} was never set.
*/
private String compiledCapabilities;
/**
* The raw, pre-built {@code <Behavior>} body for this prompt (tone, constraints,
* formatting rules), or {@code null} if not set. Usually populated via
* {@link Builder#loadBehaviorFromFile(String)}.
*/
private String behavior;
/**
* The output-format specification for this prompt (e.g. a JSON schema the model
* should reply in), or {@code null} if the model should respond in free text.
*/
private OutputFormat outputFormat;
/**
* Constructs a system prompt from its component parts. Prefer {@link #builder()}
* over calling this directly.
*
* @param identity the persona/role block, or {@code null} to omit it
* @param context the pre-built static-context body, or {@code null} to omit it
* @param capabilities the tool usage-hint definitions, or {@code null} to omit the
* capabilities section entirely
* @param behavior the pre-built behavior/constraints body, or {@code null} to omit it
* @param outputFormat the output-format specification, or {@code null} for free-text output
*/
public LLMSystemPrompt(Identity identity, String context, Capabilities capabilities, String behavior, OutputFormat outputFormat) {
this.identity = identity;
this.context = context;
this.capabilities = capabilities;
this.behavior = behavior;
this.outputFormat = outputFormat;
}
/**
* Compiles the {@code <Capabilities>} section from the tools currently registered on
* the given {@link OllamaObject}, using the usage hints defined in {@link #capabilities}.
* <p>
* This is intentionally a separate step from construction rather than something the
* constructor does automatically: which tools are registered on an {@link OllamaObject}
* can change after this prompt is built (tools added/removed at runtime), so the
* compiled capabilities text needs to be regenerated on demand against whatever the
* current tool set actually is, not frozen at construction time.
* <p>
* For each registered {@link OllamaFunctionTool}, this looks up a usage hint by the
* tool's {@link Class} via {@link Capabilities#getUsageHints()}. If no hint was
* registered for that class, the tool is silently omitted from the compiled output —
* there is no fallback to {@link OllamaFunctionTool#description()}, since that text
* is written to help the model choose a tool mid-conversation, not to explain routing
* up front in a system prompt; conflating the two would blur what each is for.
* <p>
* If {@link #capabilities} is {@code null} (this prompt doesn't use a capabilities
* section at all), this clears {@link #compiledCapabilities} to {@code null} and
* returns without inspecting {@code ollamaObject}.
*
* @param ollamaObject the object whose currently-registered tools should be inspected
*/
public void generateCapabilities(OllamaObject ollamaObject) {
if(capabilities == null) {
compiledCapabilities = null;
return;
}
StringBuilder builder = new StringBuilder();
for (Pair<OllamaTool, String> tool : ollamaObject.getTools()) {
if(tool.getKey() instanceof OllamaFunctionTool funcTool) {
String llmName = funcTool.name() + "_" + (funcTool.getSource() != null ? funcTool.getSource() : "");
String hint = capabilities.getUsageHints().get(tool.getClass());
// fall back to description() if no explicit routing hint was set
String line = hint != null ? hint : funcTool.description();
if (line == null) continue; // nothing usable to say about this tool, skip it
builder.append(llmName).append(": ").append(line).append(System.lineSeparator());
}
}
compiledCapabilities = builder.toString();
}
/**
* Renders this prompt's currently-set sections into a single system-role
* {@link OllamaMessage}, ready to be sent to Ollama.
* <p>
* Each section — {@link #identity}, {@link #context}, {@link #compiledCapabilities},
* {@link #behavior}, {@link #outputFormat} — is wrapped in its own XML-style tag and
* appended in that fixed order. A section is included only if it is non-null (and, for
* capabilities, non-blank); sections that were never set or never compiled are simply
* skipped rather than emitted as empty tags.
* <p>
* Note that {@link #compiledCapabilities} reflects whatever tool set was live the last
* time {@link #generateCapabilities(OllamaObject)} was called — call that first if the
* registered tools may have changed since.
*
* @return a new {@link OllamaMessage} with role {@link OllamaMessageRole#SYSTEM}
* containing the compiled prompt text
*/
public OllamaMessage generateSystemPrompt() {
StringBuilder builder = new StringBuilder();
if(identity != null) {
builder.append("<Identity>").append(System.lineSeparator()).append(identity.build()).append(System.lineSeparator()).append("</Identity>").append(System.lineSeparator());
}
if(context != null) {
builder.append("<Context>").append(System.lineSeparator()).append(context).append(System.lineSeparator()).append("</Context>").append(System.lineSeparator());
}
if (compiledCapabilities != null && !compiledCapabilities.isBlank()) {
builder.append("<Capabilities>").append(System.lineSeparator())
.append(compiledCapabilities)
.append(System.lineSeparator()).append("</Capabilities>").append(System.lineSeparator());
}
if(behavior != null) {
builder.append("<Behavior>").append(System.lineSeparator()).append(behavior).append(System.lineSeparator()).append("</Behavior>").append(System.lineSeparator());
}
if(outputFormat != null) {
builder.append("<Output>").append(System.lineSeparator()).append(outputFormat.build()).append(System.lineSeparator()).append("</Output>").append(System.lineSeparator());
}
return new OllamaMessage(OllamaMessageRole.SYSTEM, builder.toString());
}
/**
* Creates a new {@link Builder} for assembling an {@link LLMSystemPrompt}.
*
* @return a fresh, empty {@link Builder}
*/
public static Builder builder()
{
return new Builder();
}
/**
* The persona/role block of a system prompt — who the model is and what it's for.
* <p>
* At least one of {@code name}, {@code role}, or {@code purpose} must be non-blank;
* {@link #build()} throws if all three are absent, since an empty identity block is
* almost certainly a mistake rather than an intentional choice.
*
* @param name the model's given name (e.g. {@code "Alice"}), or {@code null}/blank to omit
* @param role the model's role (e.g. {@code "Personal assistant"}), or {@code null}/blank to omit
* @param purpose the model's purpose/goal, or {@code null}/blank to omit
*/
public record Identity(String name, String role, String purpose) {
/**
* Builds the identity sentence from whichever of {@link #name}, {@link #role}, and
* {@link #purpose} are present, joining them with commas.
*
* @return the composed identity sentence, e.g.
* {@code "You are Alice, Your role is Personal assistant"}
* @throws IllegalArgumentException if {@link #name}, {@link #role}, and
* {@link #purpose} are all {@code null} or blank
*/
public String build()
{
StringBuilder builder = new StringBuilder();
if(name != null && !name.isBlank())
{
builder.append("You are ").append(name);
}
if(role != null && !role.isBlank())
{
if(!builder.isEmpty())
{
builder.append(", ");
}
builder.append("Your role is ").append(role);
}
if(purpose != null && !purpose.isBlank())
{
if(!builder.isEmpty())
{
builder.append(", ");
}
builder.append("Your purpose is ").append(purpose);
}
if(!builder.isEmpty())
return builder.toString();
throw new IllegalArgumentException("Invalid identity");
}
}
/**
* A builder for the static-facts {@code <Context>} section of a system prompt —
* things the model should always know (e.g. "Always answer in English").
* <p>
* Facts are appended in the order they're added and joined with newlines by
* {@link #build()}. The resulting string is what gets passed to
* {@link Builder#context(String)}.
*/
public static class Context {
/**
* The ordered list of context facts added so far.
*/
ArrayList<String> facts = new ArrayList<>();
/**
* Appends a single fact to this context block.
*
* @param fact the fact text to add
* @return this {@link Context}, for chaining
*/
public Context addFact(String fact) {
facts.add(fact);
return this;
}
/**
* Returns the facts added to this context block so far, in insertion order.
*
* @return the live list of facts backing this {@link Context}
*/
public ArrayList<String> getFacts() {
return facts;
}
/**
* Joins all added facts into a single newline-separated string.
*
* @return the compiled context body, or an empty string if no facts were added
*/
public String build() {
StringBuilder builder = new StringBuilder();
for(String fact: facts) {
if(!builder.isEmpty()) builder.append("\n");
builder.append(fact);
}
return builder.toString();
}
}
/**
* Defines per-tool usage-routing hints for the {@code <Capabilities>} section of a
* system prompt — i.e. "use this tool only in these cases" guidance, distinct from a
* tool's own {@link OllamaFunctionTool#description()}.
* <p>
* Hints are keyed by the tool's {@link Class} rather than by a registered instance or
* its LLM-facing name, since NeuroDock's convention is one instance per tool class —
* see {@link #use(Class, String)}. This class only holds the hint definitions; the
* actual compiled text is produced by
* {@link LLMSystemPrompt#generateCapabilities(OllamaObject)}.
*/
public static class Capabilities {
/**
* Usage hints keyed by tool class, in the order they were registered.
*/
private final LinkedHashMap<Class<? extends OllamaFunctionTool>, String> usageHints = new LinkedHashMap<>();
/**
* Registers a usage-routing hint for a tool class — guidance on when the model
* should reach for this tool, e.g. {@code "This should only be used when needing
* to run CLI tools"}.
* <p>
* Keyed by class rather than instance because a tool's LLM-facing name (which
* depends on its registration source) isn't known until it's actually registered
* on an {@link OllamaObject}; class identity is stable and known up front.
*
* @param toolClass the tool class this hint applies to
* @param usageHint the routing guidance text for this tool
* @return this {@link Capabilities}, for chaining
*/
public Capabilities use(Class<? extends OllamaFunctionTool> toolClass, String usageHint) {
usageHints.put(toolClass, usageHint);
return this;
}
/**
* Returns the usage hints registered so far, keyed by tool class.
*
* @return the live map backing this {@link Capabilities}
*/
Map<Class<? extends OllamaFunctionTool>, String> getUsageHints() {
return usageHints;
}
}
/**
* The behavior/constraints section of a system prompt — tone, formatting rules, and
* other "how to behave" instructions.
* <p>
* <b>Not yet implemented</b> — currently unused; {@link LLMSystemPrompt#behavior} is
* populated directly as a {@link String}, typically via
* {@link Builder#loadBehaviorFromFile(String)}, rather than through this class.
*/
public static class Behavior {
/**
* Structured behavioral constraints. Not yet implemented.
*/
Constraints constraints;
/**
* Output formatting rules. Not yet implemented.
*/
String formating;
// TODO: Implement this
/**
* Structured constraints for a {@link Behavior} block (tone, length limits, things
* to avoid, etc.).
* <p>
* <b>Not yet implemented.</b>
*/
public static class Constraints
{
// TODO: Implement this
}
}
/**
* Specifies the expected output format for a system prompt's {@code <Output>} section
* — e.g. a JSON schema the model should structure its replies as.
*/
public static class OutputFormat {
/**
* The raw format specification, rendered verbatim in the {@code <Output>} section.
*/
String format;
/**
* Creates an output format from a raw, pre-formatted string.
*
* @param format the format specification text
*/
public OutputFormat(String format) {
this.format = format;
}
/**
* Creates an output format from a JSON schema.
*
* @param format the JSON schema to use as the format specification
*/
public OutputFormat(JSONObject format) {
this.format = format.toString();
}
/**
* Returns the format specification text for rendering into the prompt.
*
* @return the raw format string
*/
public String build () {
return format;
}
}
/**
* Builder for assembling an {@link LLMSystemPrompt} from its component sections.
* Obtain an instance via {@link LLMSystemPrompt#builder()}.
*/
public static class Builder {
/**
* The identity block to use, or {@code null} to omit it. Set via {@link #identity(Identity)}.
*/
private Identity identity;
/**
* The pre-built context body to use, or {@code null} to omit it. Set via {@link #context(String)}.
*/
private String context;
/**
* The capabilities/usage-hint definitions to use, or {@code null} to omit the
* capabilities section. Set via {@link #capabilities(Capabilities)}.
*/
private Capabilities capabilities;
/**
* The pre-built behavior body to use, or {@code null} to omit it. Set via
* {@link #behavior(String)} or loaded from disk via {@link #loadBehaviorFromFile(String)}.
*/
private String behavior;
/**
* The output format specification to use, or {@code null} for free-text output.
* Set via {@link #outputFormat(OutputFormat)}.
*/
private OutputFormat outputFormat;
/**
* Loads {@link #behavior} from a text file, resolved in order against:
* <ol>
* <li>the classpath</li>
* <li>{@link Core#DATA_DIR}</li>
* <li>the current runtime/working directory</li>
* <li>an exact file path match</li>
* </ol>
*
* @param path the file to look for, resolved as described above
* @return this {@link Builder}, for chaining
* @throws IllegalStateException if no file could be found at any of the candidate
* locations, or if it exists but cannot be read
*/
public Builder loadBehaviorFromFile(String path) {
behavior = BehaviorLoader.load(path);
return this;
}
/**
* Sets the identity/persona block for the prompt being built.
*
* @param identity the identity to use
* @return this {@link Builder}, for chaining
*/
public Builder identity(Identity identity) {
this.identity = identity;
return this;
}
/**
* Sets the pre-built static-context body for the prompt being built.
*
* @param context the context body, typically produced via {@link Context#build()}
* @return this {@link Builder}, for chaining
*/
public Builder context(String context) {
this.context = context;
return this;
}
/**
* Sets the capability usage-hint definitions for the prompt being built. These are
* only compiled into prompt text once
* {@link LLMSystemPrompt#generateCapabilities(OllamaObject)} is called on the built
* instance.
*
* @param capabilities the usage-hint definitions to use
* @return this {@link Builder}, for chaining
*/
public Builder capabilities(Capabilities capabilities) {
this.capabilities = capabilities;
return this;
}
/**
* Sets the pre-built behavior body for the prompt being built directly, bypassing
* {@link #loadBehaviorFromFile(String)}.
*
* @param behavior the behavior/constraints text to use
* @return this {@link Builder}, for chaining
*/
public Builder behavior(String behavior) {
this.behavior = behavior;
return this;
}
/**
* Sets the output format specification for the prompt being built.
*
* @param outputFormat the output format to use
* @return this {@link Builder}, for chaining
*/
public Builder outputFormat(OutputFormat outputFormat) {
this.outputFormat = outputFormat;
return this;
}
/**
* Builds the {@link LLMSystemPrompt} from the sections configured on this builder.
*
* @return a new {@link LLMSystemPrompt}
*/
public LLMSystemPrompt build() {
return new LLMSystemPrompt(identity, context, capabilities, behavior, outputFormat);
}
}
/**
* Internal helper for resolving and reading a behavior file from one of several
* candidate locations. See {@link Builder#loadBehaviorFromFile(String)}.
*/
private static class BehaviorLoader {
/**
* Checks whether a classpath resource exists at the given path without leaving it open.
*
* @param path the classpath-relative resource path to check
* @return {@code true} if a resource stream could be opened at that path
*/
private static boolean classpathResourceExists(String path) {
try (InputStream is = BehaviorLoader.class.getResourceAsStream(path)) {
return is != null;
} catch (IOException e) {
return false;
}
}
/**
* Resolves and reads a behavior file as UTF-8 text, checking the classpath,
* {@link Core#DATA_DIR}, the working directory, and an exact path match, in that order.
*
* @param path the file to look for
* @return the full file contents, with each line terminated by {@code \n}
* @throws IllegalStateException if the resource cannot be found at any candidate
* location, or if it exists but cannot be read
*/
public static String load(String path) {
try {
InputStream in;
if (classpathResourceExists("/"+path)) {
in = BehaviorLoader.class.getResourceAsStream("/"+path);
} else {
Path dataDirPath = Path.of(Core.DATA_DIR.getPath(), path);
Path relativePath = Path.of(path);
if (Files.exists(dataDirPath)) {
in = new FileInputStream(dataDirPath.toFile());
} else if (Files.exists(relativePath)) {
in = new FileInputStream(relativePath.toFile());
} else {
throw new IllegalStateException("Could not find resource " + path);
}
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append('\n'); // see note below
}
return sb.toString();
}
} catch (IOException e) {
throw new IllegalStateException("Could not load resource " + path, e);
}
}
}
}
@@ -19,6 +19,15 @@ public abstract class OllamaFunctionTool implements OllamaTool {
*/
protected String source = "";
/**
* returns the source of this tool
* @return String source
*/
public final String getSource()
{
return source;
}
@Override
public String toString() {
JSONObject ret = new JSONObject();
@@ -1,6 +1,7 @@
package me.neurodock.ollama;
import me.neurodock.core.Core;
import me.neurodock.core.LLMSystemPrompt;
import me.neurodock.core.LaunchOptions;
import me.neurodock.core.Pair;
import me.neurodock.core.files.FileHandlerLocation;
@@ -66,7 +67,7 @@ public class OllamaObject {
* @param stream If the Ollama Object is streamed. see {@link OllamaObject#stream}
* @param keep_alive The keep alive of the Ollama Object. see {@link OllamaObject#keep_alive}
*/
private OllamaObject(String model, ArrayList<OllamaMessage> messages, ArrayList<Pair<OllamaTool, String>> tools, JSONObject format, Map<String, Object> options, boolean stream, String keep_alive) {
private OllamaObject(String model, ArrayList<OllamaMessage> messages, ArrayList<Pair<OllamaTool, String>> tools, JSONObject format, Map<String, Object> options, boolean stream, String keep_alive, LLMSystemPrompt prompt) {
this.model = model;
this.messages = messages;
@@ -121,7 +122,7 @@ public class OllamaObject {
} else {
message = new OllamaMessageToolCall(OllamaMessageRole.fromRole(obj.getString("role")), obj.getString("content"), obj.getJSONArray("tool_calls"));
}
messages.add(message);
this.messages.add(message);
}
}catch (Exception e) {
System.out.println("Error loading old data");
@@ -133,6 +134,36 @@ public class OllamaObject {
System.out.println("No old data found, skipping loading old data.");
}
}
if(prompt == null) {
// Do nothing
}
else if(!this.messages.isEmpty())
{
OllamaMessage systemPrompt = this.messages.getFirst();
if(systemPrompt.getRole() != OllamaMessageRole.SYSTEM)
{
System.out.println("FIRST MESSAGE ISN'T A SYSTEM PROMPT!. Pushing all messages forward and injecting the system prompt");
Core.writeLog("FIRST MESSAGE ISN'T A SYSTEM PROMPT!. Pushing all messages forward and injecting the system prompt");
ArrayList<OllamaMessage> newMessages = new ArrayList<>();
newMessages.add(prompt.generateSystemPrompt());
newMessages.addAll(messages);
this.messages = newMessages;
}
else
{
System.out.println("Replacing System prompt...");
Core.writeLog("Replacing System prompt...");
systemPrompt = prompt.generateSystemPrompt();
this.messages.set(0, systemPrompt);
}
}
else
{
System.out.println("No old prompts. Inserting system prompt...");
Core.writeLog("No old prompts. Inserting system prompt...");
this.messages.add(prompt.generateSystemPrompt());
}
}
/**
@@ -253,13 +284,31 @@ public class OllamaObject {
}
/**
* Adds a system message to the messages.
* <p>
* Equvalent to {@code OllamaObject#addMessage(new OllamaMessage(OllamaMessageRole.SYSTEM, <message>))}
* @param message
* Sets the system message, replacing any existing one at the start of {@link #messages},
* or inserting one at the start if none exists yet.
* @param message the system message text
*/
public void addSystemMessage(String message) {
messages.add(new OllamaMessage(OllamaMessageRole.SYSTEM, message));
public void setSystemMessage(String message) {
applySystemPrompt(new OllamaMessage(OllamaMessageRole.SYSTEM, message));
}
/**
* Replaces the current system prompt if one exists at the start of {@link #messages},
* or inserts one at the start if none exists yet.
*/
public void setSystemPrompt(LLMSystemPrompt prompt) {
applySystemPrompt(prompt.generateSystemPrompt()); // shared with the constructor
}
private void applySystemPrompt(OllamaMessage message) {
if (message == null) return;
if (messages.isEmpty()) {
messages.add(message);
} else if (messages.getFirst().getRole() != OllamaMessageRole.SYSTEM) {
messages.addFirst(message);
} else {
messages.set(0, message);
}
}
@Override
@@ -330,6 +379,10 @@ public class OllamaObject {
* The keep alive of the Ollama Object.
*/
String keep_alive;
/**
* This represents the System Prompt for the LLM
*/
LLMSystemPrompt systemPrompt;
/**
* Creates a new instance of {@link OllamaObjectBuilder}.
@@ -521,13 +574,19 @@ public class OllamaObject {
//throw new IllegalArgumentException("FileHandler is not supported yet!");
return addTools(FileHandler.getTools());
}
public OllamaObjectBuilder setSystemPrompt(LLMSystemPrompt systemPrompt) {
this.systemPrompt = systemPrompt;
return this;
}
/**
* Builds the {@link OllamaObject}
* @return The {@link OllamaObject}
*/
public OllamaObject build() {
return new OllamaObject(model, messages, tools, format, options, stream, keep_alive);
return new OllamaObject(model, messages, tools, format, options, stream, keep_alive, systemPrompt);
}
}
}