Redouble AI Nucleo documentation
Part 2 · Writing an agent

Prompts

ai.redouble.nucleo.prompt · nucleo-core

The instructions a thinker gives its model are text that people will want to change after the code ships: reworded for one customer, tried in two variants, kept in a database where an operator edits them. If the instructions are a string built inside the thinker, every change is a release. In Nucleo they are prompts: units of instruction registered under a key, which a deployment can substitute without touching the class, and which guardrails check before any model reads them.

A thinker's instructions

Your first agent already uses the simplest form. OrderAgent returns its instructions from getSystemPromptText() and marks the method @StaticPrompt:

public class OrderAgent extends SingleObjectiveThinker<OrderQuestion, OrderAnswer> {
    public OrderAgent(Identifiable parent) {
        super(parent, new ThinkerDeclaration(Grade.SMALL, OutputSize.COMPACT));
        setAnswerHandler(new PojoResponseHandler<>(OrderAnswer.class));
    }

    @StaticPrompt
    @Override
    protected String getSystemPromptText() {
        return """
            You answer a customer's question about their orders. Look up every order the
            question is about with order_status before you answer, and state each status as
            the lookup returned it.
            """;
    }

    @Override
    protected List<Class<? extends Tool>> declareDefaultTools() {
        return List.of(OrderStatusTool.class);
    }
}

The first time the thinker runs, its text is registered as the default for a key named after the thinker class, here ai.redouble.examples.agent.OrderAgent, and from then on the thinker takes its instructions from the registry under that key. A deployment that substitutes that key (below) changes what the agent is told, and the class stays as it is. @StaticPrompt declares that the text never changes, which lets Nucleo check it once and keep it.

Prompts declared by key

A prompt shared by several classes, or one an operator should find under a stable name, is declared on a static field with an explicit key, and the thinker produces it by that key:

public class SupportThinker extends SingleObjectiveThinker<...> {
    @StaticPrompt("support.triage.system-msg")
    public static final String SYSTEM_MSG = """
            You are a support triage expert...
            """;

    @Override
    protected Prompt getSystemPrompt() throws LLMReadableCheckedException {
        return Prompts.produce("support.triage.system-msg");
    }
}

Name keys <domain>.<feature>.<role>: hierarchical, lowercase, without colons. The annotation's value can be left out, and the key is then derived from where it stands: <class>.<field> for a static field or method, <class> for a class. The colon is reserved for keys Nucleo mints itself (inline:<hash> for Prompts.of one-shots, skillsjars:<skill>:... for the text of skills), which is why your keys never collide with them.

@StaticPrompt goes on a static final String, on a static final field holding a StaticPromptSource, on a static method with no arguments returning one, or on a class that implements StaticPromptSource and has a no-argument constructor. Nucleo finds these declarations by scanning the classpath the first time any prompt is produced. The scan covers the package ai.redouble unless you name another with Prompts.setDefaultScanPackage before the first prompt is produced, or scan one yourself with Prompts.scanPackage. There is one scan per process: once any scan has run, scanPackage does nothing. Prompts.warmCache() produces every registered static prompt at startup, so that no agent pays for the first check.

Instructions that change per call: @DynamicPrompt

Some content legitimately varies: today's date, an environment flag, a feature flag. Declare it as a PromptSource, a function from the key to the content, marked @DynamicPrompt:

@DynamicPrompt("rocket.claim-line-id.system-msg")
public static final PromptSource SYSTEM_MSG = key -> {
    String today = LocalDate.now().format(DateTimeFormatter.ofPattern("MMMM d, yyyy"));
    return TextNode.valueOf("...TODAY'S DATE: " + today + "...");
};

A dynamic prompt is produced and checked on every call, which costs something; use @StaticPrompt whenever the content does not really vary. A String field cannot be dynamic and is refused. A source may read state of the whole process (the clock, the environment, a feature registry), and nothing that belongs to one call: data that varies per invocation - the thinker's fields, the caller's objects - belongs in the thinker's input, where the model reads it beside the prompt.

Substituting a prompt

A deployment changes what a key produces without touching the code that declared it:

Prompts.produce(key) looks first for a substitution of the key, then for the global source, then for the default the code declared, and throws PromptNotFoundException when there is none. A prompt from a static source is kept after its first production; a substitution replaces what was kept. Prompt sources lists the sources Nucleo ships - text held in a database, fetched from a server, or split between two variants - so a deployment rarely writes its own.

Two more ways to register a prompt without an annotation:

Prompts.of(text) builds a one-time prompt under a key made from its content, registered nowhere: nothing can substitute it, and the baseline guardrails still check it.

Checking prompts: guardrails

A prompt is text that reaches a model, and a deployment may want every one checked: not too long, no instruction to leak data. Attach checks with a guardrail factory:

Prompts.addBaselineGuardrail(() -> new SizeCapGuardrail(Job.workflow("platform", "prompt-checks"), 20_000));

Each takes a factory, because a guardrail is a job and runs once: Nucleo builds a fresh one for every check. Prompts.produce runs the checks and waits for their verdict, and a verdict is remembered for the same content under the same checks, so unchanged content is checked once. A refused prompt throws GuardrailException. Nothing is attached by default: a deployment wires the two shipped checks, SizeCapGuardrail (a limit on length) and ExfiltrationMarkerGuardrail, itself. Both hold no resources, so checking a prompt from inside an agent's loop cannot deadlock.

Checks stay attached to the key when its source is substituted, so a deployment cannot bypass them by swapping the source.

What to avoid

How it works inside

The types of this package, the resolution order and how its cache stays consistent under concurrent substitution, discovery at build time, and the wire shape of a serialized prompt are in Inside prompts, for those working on the runtime itself.

^