Skip to content

Feature: LLM-backed decision and classification services - #2074

Open
jimador wants to merge 37 commits into
feature/jev-primitivesfrom
feature/llm-decision-provider
Open

jimador wants to merge 37 commits into
feature/jev-primitivesfrom
feature/llm-decision-provider

Conversation

@jimador

@jimador jimador commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Decision and classification services answered by a chat model, built on the contracts in #2062. Stacked on #2061, which adds the TypeSafe services for the same contracts.

LlmDecisionServiceFactory builds a DecisionService or a ClassificationService from a model name or from an LlmService the caller already holds. The platform autoconfiguration registers the factory as a bean. Services can also be declared in configuration, and the platform autoconfiguration registers each one as a DecisionService bean named after its key.

classDiagram
    ClassificationService <|-- DecisionService
    LlmDecisionServiceFactory --> DecisionService : decisionService(llm)
    LlmDecisionServiceFactory --> ClassificationService : classificationService(llm)
    LlmDecisionServicesAutoConfiguration --> LlmDecisionServiceFactory : bean
    LlmDecisionServicesAutoConfiguration --> LlmDecisionServicesRegistrar : registers
    LlmDecisionServicesRegistrar --> LlmDecisionServiceFactory : builds configured services
    DecisionService <|.. ObservedDecisionService
    ClassificationService <|.. ObservedClassificationService
    ObservedDecisionService --> LlmDecisionService
    ObservedClassificationService --> LlmClassificationService
    LlmClassificationService --> LlmDecisionService
    LlmDecisionService --> LlmOperations
Loading
flowchart LR
    App[Application] --> Observed[ObservedDecisionService]
    Observed --> Service[LlmDecisionService]
    Service --> Retry[Retry template]
    Retry --> Ops[LlmOperations]
    Ops --> Model[Chat model]
Loading
DecisionService triage = factory.decisionService("small-chat-model");
DecisionService byok = factory.decisionService(validatedLlm);
embabel:
  agent:
    platform:
      decisions:
        llm:
          max-attempts: 4
          services:
            routing:
              llm: fast-chat-model

Behavior:

  • A named model is resolved once, when the service is built. Every call uses that exact model.
  • A supplied LlmService is used as is, which covers BYOK: validate the key with the provider's BYOK factory and pass the result.
  • The model answers with a structured verdict. SELECTED, NO_MATCH and INCONCLUSIVE map to Selected, NoMatch and Inconclusive. TRUE, FALSE and INCONCLUSIVE map to Answered(true), Answered(false) and Inconclusive. Results carry no confidence: the answer schema has no confidence field, and the prompt tells the model not to report one.
  • A verdict that breaks the rules, such as an unknown category ID, is Failure(INVALID_RESPONSE). A reply that cannot be read is retried and then becomes Failure(INVALID_RESPONSE). A failed model call is retried and then becomes Failure(UNAVAILABLE). The defaults are 5 attempts counting the first, a 100 ms first wait, a multiplier of 5 and at most 180000 ms between attempts.
  • An interruption during the model call or the retry wait throws an unchecked CancellationException caused by the InterruptedException, and leaves the thread's interrupt flag set. The observation records the interrupted outcome.
  • Input text goes to the model as the input field of a JSON object, so empty input and input that looks like instructions reach it unchanged.
  • The classification prompt puts the request's instructions on a Question: line before the categories.
  • Each call records one embabel.ai.decision or embabel.ai.classification observation. The underlying model call keeps its own observation.
  • The retry settings bind as @ConfigurationProperties under embabel.agent.platform.decisions.llm and apply to every service the factory builds. The factory takes them as a constructor argument. A setting out of range fails binding, and the cause names the setting.
  • LlmDecisionServicesAutoConfiguration in embabel-agent-platform-autoconfigure supplies the factory bean, binds the retry settings and registers the configured services. The factory bean backs off when the application defines its own. Core has no Spring Boot wiring for decisions. An entry names its llm, nothing else. An unknown LLM name or an unknown key under a service in configuration files fails startup, and the error names the property. Keys set through environment variables or system properties are not checked.
  • The factory bean is @ConditionalOnMissingBean. Configured services are built through whichever factory bean is present, so an application's own factory builds them too.

No checked exception escapes classify or assess, so Java callers never have to handle one. #2080 gives the TypeSafe services and ask the same convention.

The API is experimental. The factory's public surface is its four builder methods. The reference page, embabel-agent-docs/src/main/asciidoc/reference/llm-decisions/page.adoc, covers configuration, results, failures, interruption and observations.

@jimador
jimador added this pull request to stack #2067 September 26, 2026 21:20
@jimador jimador changed the title feature/llm decision provider Feature: LLM-backed decision and classification services Sep 26, 2026
@jimador
jimador marked this pull request as ready for review September 26, 2026 21:20
@jimador
jimador removed this pull request from stack #2067 September 27, 2026 22:10
@jimador
jimador added this pull request to stack #2081 September 27, 2026 22:11
@jimador
jimador force-pushed the feature/llm-decision-provider branch from a221032 to f699ff3 Compare September 28, 2026 05:19
@jimador
jimador requested a review from igordayen September 28, 2026 06:14
@jimador
jimador force-pushed the feature/llm-decision-provider branch from f699ff3 to ea9647a Compare September 28, 2026 16:07
@jimador
jimador force-pushed the feature/llm-decision-provider branch from ea9647a to 651d3d4 Compare September 28, 2026 16:48
@jimador
jimador removed this pull request from stack #2081 September 28, 2026 16:48
@jimador
jimador added this pull request to stack #2087 September 28, 2026 16:49
Classification input goes only into the user message, and a structured
answer is checked against the request's categories before it becomes a
result. Answers that break the verdict rules are rejected without echoing
the input.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Request contracts allow empty input, which a plain user message cannot
carry. The envelope keeps input as escaped data and is never empty.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Classify and assess calls go through the retry template and every outcome maps to a contract result. Unreadable or rule-breaking answers are invalid responses, other failures are unavailability, and interruption is rethrown with the interrupt flag set.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Only an interrupted model call escapes as InterruptedException. An
interrupt that lands in the wait between attempts now follows the
failure table and comes back as unavailable, leaving the thread's
interrupt flag set.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…supplied model

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Each entry under embabel.agent.platform.decisions.llm.services becomes a
decision or classification service bean named by its key, with its own
retry settings. Definitions are typed and eager, so the model provider's
LLM lookup skips them and an unknown LLM fails startup naming the property.
The platform also exposes an LlmDecisionServiceFactory bean.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…eption

An interrupt that lands in the wait between attempts now leaves the
service the same way as an interrupted model call: as the original
InterruptedException with the thread's interrupt flag set. Spring
Retry reports the interrupted wait as BackOffInterruptedException with
the InterruptedException as its cause, so decide looks for an
interruption anywhere in the cause chain before mapping a failure.
Observation records these decisions as interrupted instead of failed.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The factory constructor takes the internal LlmOperations type, so it is
now internal and applications inject the llmDecisionServiceFactory bean.
The Java consumer examples are renamed to LlmDecisionServiceJavaTest so
Surefire's default includes pick them up in every build.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…llers

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The tag::named[] and tag::supplied[] regions used in the reference docs
called the internal LlmDecisionServiceFactory constructor directly.
Applications inject the factory bean instead, so both regions now use a
factory field built outside the tagged code, matching what real callers
would write.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…rruption

An interrupted blocking read can surface as ClosedByInterruptException or
another I/O error with no InterruptedException in the chain. The service now
rethrows that as InterruptedException("Decision interrupted") caused by the
failure, both inside the retry guard and in decide, and keeps the flag set.
A SocketTimeoutException with the flag clear stays a retryable failure.

The retry template name is now a constructor parameter, defaulting to
decision-<model>. The decide KDoc now says what this class logs.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
The backoff test now uses a sleeper that throws an InterruptedException at
once, the way Thread.sleep does when interrupted. It asserts that exact
instance comes back. It no longer relies on a thread that interrupts the
caller after a delay.

A new test covers a flag that is already set before a successful call: the
result comes back and the flag stays set. The guard-path test is renamed
for the path it exercises, and the decide KDoc says this class logs only
the operation, model name and outcome.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…try fields

The unknown-key check compared an environment variable such as
EMBABEL_AGENT_PLATFORM_DECISIONS_LLM_SERVICES_TRIAGE_MAX_ATTEMPTS, which the
binder sees as max.attempts, with the bound name max-attempts. It reported the
variable as unbound and failed startup on valid configuration.

The services map is now bound with UnboundElementsSourceFilter, as Spring Boot
does for ignoreUnknownFields = false. Unknown keys in configuration files still
fail startup. Unknown keys from environment variables and system properties are
not checked.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Point the Java examples at LlmDecisionServiceJavaTest. Drop the direct
construction guidance and constructor table, since applications inject
the factory bean. Describe interruption during the retry wait, the
primary ObservationRegistry case, and the retry template's INFO logging
of failed attempts.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…n and failure rules

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ry ID as absent

Category descriptions are joined into the classification prompt after
trimMargin, so a description line that starts with '|' reaches the model
unchanged. NO_MATCH and INCONCLUSIVE now accept an empty or whitespace
category ID, the same way SELECTED already treats it as missing.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ilure into an interruption

The flag fallback now lives only in the guard around the model call. The
decision itself treats only an InterruptedException in the cause chain as an
interruption, so a rule-breaking answer is an invalid response whatever the
caller's flag says, and the flag is left as the caller set it.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…ter the model call

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…servation and three child chat model observations

Builds the service through the factory over ChatClientLlmOperations and a
chat model that records its own observation on each call. The first two
calls fail with a transient error and the third answers.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…rs by hand

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…prompt

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>

@igordayen igordayen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimador - major blocking concern is on LLmDecisionServiceConfiguration location.
thanks

@igordayen

Copy link
Copy Markdown
Contributor

@jimador - please resolve merge conflicts.
thanks

@jimador
jimador force-pushed the feature/llm-decision-provider branch from 651d3d4 to 9c00745 Compare September 28, 2026 22:06
@igordayen

Copy link
Copy Markdown
Contributor

from CODEX:

Findings

  • P1 LlmDecisionServiceConfiguration.kt:32 I agree with your blocking comment. LlmDecisionServiceConfiguration is in embabel-agent-api, but it is doing
    Boot-specific configuration work: ConditionalOnMissingBean, Boot Binder, NoUnboundElementsBindHandler, and a BeanDefinitionRegistryPostProcessor. This
    is not just API/SPI code; it is autoconfiguration-style wiring. Keeping it in API means the core platform configuration imports this feature directly
    via AgentPlatformConfiguration.kt:77, instead of letting an autoconfigure module own conditional registration. I’d move this to the appropriate
    autoconfigure/platform-autoconfigure module and leave embabel-agent-api with the factory/service implementation contracts only.

  • P2 LlmDecisionService.kt:73 The implementation can throw InterruptedException through ClassificationService.classify / DecisionService.assess, but the
    Feature: implementation of classification and decision service contracts #2062 contracts do not declare it. The PR body calls this out, so it is known, but it is still an awkward Java API: callers cannot catch
    InterruptedException directly around the contract method. I would not ship this behavior until the contract is aligned, or use the same unchecked/
    cancellation convention as TypeSafe until the checked exception is added.

Your other comments make sense as cleanup once the config is moved: using EnableConfigurationProperties, implementing/reusing RetryProperties, naming
retryTemplate, and using Registry spelling are all good consistency fixes.

thanks

… properties

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…e platform autoconfiguration

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…service retry

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
…cellation

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>

@igordayen igordayen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimador - just flagging depency on autoconfig outside autoconfig, please consider moving, thanks

… autoconfiguration

The factory bean and its retry settings move out of embabel-agent-api, so core keeps no Spring Boot wiring for decisions. The factory takes its retry settings as a constructor argument.

Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
Signed-off-by: James Dunnam <7660553+jimador@users.noreply.github.com>
@sonarqubecloud

Copy link
Copy Markdown

@jimador
jimador requested a review from igordayen September 29, 2026 02:52

@igordayen igordayen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimador looks good, thanks for addressing inquiries

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants