Redouble AI Nucleo documentation
Part 3 · The process stays in charge

Failures that behave

ai.redouble.nucleo.harness.errors · nucleo-core

When a tool fails inside an agent, the model that called it has to decide what to do next, and it can decide only from what it is told. A stack trace tells it nothing it can act on, and a NullPointerException says even less. The model needs two answers: what went wrong, in plain words, and whether trying again can help - with a corrected call, or only by taking another way.

In Nucleo a tool gives both answers by the exception it throws. Your first tool threw InvalidInputException for a malformed order number and ResourceNotFoundException for one that does not exist, and your first agent showed the model reading the first and calling again with the right number. This page is the set of exceptions to choose from, and what each one tells the model.

What the model reads

A failed tool call does not stop the thinker that made it. The failure becomes the result of that call, marked as an error, and the model reads it on its next turn like any other result. The text is the exception's message for the model, followed by one line that says whether retrying can help:

A tool that throws an exception outside Nucleo's set gives the model nothing to go on: it reaches the model as a SystemException, which reads A system error occurred in <component>. The operation could not be completed. The details go to the log, and the model learns only that something broke. Correctable failures are logged as warnings, since they are the normal back-and-forth of a model correcting itself; uncorrectable ones as errors, with the stack trace.

Every Nucleo exception carries two messages, one for each reader:

MethodForExample (SystemException)
getMessage()the log and the developer: technical"System error in Tool instantiation: Failed to create instance of com.foo.MyTool"
getLLMMessage()the model: plain and actionable"A system error occurred in Tool instantiation. The operation could not be completed."

The unchecked ones described below carry one message: a CorrectableRuntimeLLMException or UncorrectableRuntimeLLMException is constructed from its message for the model, and getMessage() returns that same string. explainToLLM() is the message together with the line about retrying, the text the model reads.

Correctable or not

The first choice is whose problem the failure is.

Correctable (isCorrectable() is true): the call was wrong, and the model may fix its arguments and call again.

// the model sent a blank query
throw new InvalidInputException("query", "", "must not be blank");
// the model asked for an article that does not exist
throw new ResourceNotFoundException("PMC article", "PMC12345");

Uncorrectable (isCorrectable() is false): the tool cannot do this right now, whatever the input, and the model should try something else.

// the service never answered; a status that did come back is mapped for you (below)
throw new ExternalServiceException("PubChem", "connection refused", e);
// credentials rejected
throw new UnauthorizedException("ChemSpider", "API key expired");

Search or fetch

Whether "nothing found" is a failure depends on what the tool was asked.

SEARCH (query-based, list results):
  API reachable?
    NO  -> ExternalServiceException
    YES -> Results found?
      NO  -> return empty output (VALID RESULT)
      YES -> return populated output

FETCH-BY-ID (single resource by identifier):
  API reachable?
    NO  -> ExternalServiceException
    YES -> Resource exists?
      NO (404)  -> ResourceNotFoundException
      YES       -> return populated output

A search that finds nothing has answered the question, and an empty result tells the model so. A fetch by an identifier that names nothing is the model's mistake to correct.

Failures from HTTP services

A tool that calls an HTTP service does not choose these exceptions by hand for each status. The status is mapped onto the same set, by what the model should do about it:

400/413/422 -> InvalidInputException          (correctable)
401/403     -> UnauthorizedException          (unless the body says throttle - see below)
404         -> ResourceNotFoundException      (correctable; fetch-by-ID only)
429         -> Http429Exception, an UpstreamThrottleException
500+        -> ExternalServiceException

Each mapped status has a class of its own, Http400Exception through Http503Exception, placed under the exception whose meaning it has, and each also implements HttpErrorResponse, which carries what the server actually said. That gives two ways to catch one:

catch (ExternalServiceException e) {
    if (e instanceof HttpErrorResponse http) {
        log.warn("upstream {} at {}", http.getStatusCode(), http.getEndpoint());
    }
}

A status exists only when a response came back. A connection refused or a read timeout is an ExternalServiceException that is not an HttpErrorResponse at all, and so tells a failure to reach the service apart from an answer the service gave.

Never add an exception type for one service. A service's quirk, such as EPO answering robot detection (CLIENT.RobotDetected) with a 403, is mapped by that service's client, in its classify override, onto an existing type. HTTP status exceptions has the factory, the classes and the client base that does this mapping.

Throttling

When a service signals that it is being called too fast, the tool throws UpstreamThrottleException, or its specialization Http429Exception, and lets it propagate. Nucleo does the rest: when a job fails with any ExternalServiceException and has rate limiters of its own attached, the dispatcher tells each of them, so they slow down and, if the failures continue, stop sending altogether until the service recovers (their circuit opens). Each limiter records what the server said, the status, the detail and any Retry-After it asked for, and quotes it in its throttle and circuit messages, so a tool switched off by an open circuit names the response that switched it off.

SignalException
HTTP 429Http429Exception
HTTP 403 with a robot-detection body (EPO CLIENT.RobotDetected)Http429Exception, raised by EPOClient.classify before the status mapping
A throttle header or structured response particular to the APIUpstreamThrottleException
Server 500/502/503Plain ExternalServiceException

Tools throw, the HTTP clients map statuses, the dispatcher signals the limiters. None of these is caught by the tool itself.

Wrapping what you call

A tool body calls code that throws other exceptions: a client's IOException, a handle's ExecutionException. Two methods turn them into what the model can read.

// wrap anything: an LLM-readable exception in the cause chain is kept, anything else
// becomes a SystemException
try {
    apiClient.call(params);
} catch (IOException e) {
    throw LLMReadableCheckedException.unwrap(e);
}

// a service call made with a parameter the model supplied: name the service, the
// parameter, its value and the step
throw LLMReadableCheckedException.wrapWithContext(e, "PubChem", "cid", cid, "compound fetch");

wrapWithContext keeps the correctable or uncorrectable verdict of what it wraps and addresses it to what the model knows. A correctable failure anywhere in the cause chain comes back as an InvalidInputException naming the parameter and its value, with the step and the buried message as the rule. That is why a 404 on a fetch comes back as an InvalidInputException: the client mapped it to the correctable ResourceNotFoundException, and the wrap readdresses it to the parameter the model sent. An uncorrectable failure, or one with no LLM-readable exception in it, comes back as an ExternalServiceException naming the service, with the step and the buried message (or the deepest raw message). Wrap the one service call this way, and keep the outer unwrap for whatever else the body throws ("Service tools" in Tools, thinkers and doers).

Neither method swallows the signals Nucleo uses to retry a call on its own: unwrap and wrapWithContext rethrow any of them found in the cause chain, so a broad catch never turns a retry into a failure (Retry signals).

Checked and unchecked

Tools, thinkers and doers throw the checked exceptions above, subclasses of CorrectableLLMException and UncorrectableLLMException: execute declares LLMReadableCheckedException. Where a checked exception cannot propagate, inside a stream, a lambda, a callback or enum deserialization, throw CorrectableRuntimeLLMException or UncorrectableRuntimeLLMException. The model reads both kinds the same way.

// checked - normal tool code
public MyOutput execute(JobResources resources, JobContext context)
        throws LLMReadableCheckedException {
    throw new InvalidInputException("query", value, "must not be blank");
}

// unchecked - inside a stream
results.stream().map(r -> {
    if (r.isInvalid()) {
        throw new CorrectableRuntimeLLMException("Invalid result: " + r.getId());
    }
    return transform(r);
});

Code with no throws clause turns a caught exception into an unchecked one with unwrapRuntime:

throw LLMReadableRuntimeException.unwrapRuntime(e, "PubChem search failed");
// an unchecked LLM-readable exception in the chain -> returned as itself
// a checked one -> the matching unchecked class with the same message, correctable stays
//   correctable
// none -> UncorrectableRuntimeLLMException with the fallback message

unwrapRuntime does not rethrow the retry signals, because the code it serves is not on the dispatcher's retry path and never meets one.

Failures the runtime raises

Some exceptions of this set are thrown by Nucleo itself, and code that submits jobs may catch them: a job stopped by a cancel (JobCancelledException) or by its time budget (JobTimeoutException); a conversation that does not fit the model's window even after compaction (ContextOverflowException); a prompt key no source holds (PromptNotFoundException); a model id the catalog does not hold (ModelNotFoundException); a provider account out of money (QuotaExhaustedException); a job refused under its workflow's spend cap (SpendCapExceededException); and a provider that declined the call (ProviderRefusalException), which carries the model, the category and the provider's explanation for a caller that resubmits elsewhere. All of them are uncorrectable.

How it works inside

The whole hierarchy, where each member lives, and the exceptions that are addressed to the runtime and never to the model are in Inside the exception hierarchy, for those working on the runtime itself.

Example: Your first agent, when the order number is wrong - a tool refuses a malformed number with InvalidInputException, and the model corrects its call.

^