Skip to content
tnsaijava agent framework

SPI Reference

TnsAI.Core uses Java's ServiceLoader mechanism extensively for cross-module extensibility. SPI interfaces define contracts in the core module; implementations live in optional modules and are discovered at runtime via META-INF/services/ registration.

How SPI Works in TnsAI

  1. Core defines an interface (e.g., CheckpointerProvider)
  2. An optional module implements it (e.g., PostgresCheckpointerProvider)
  3. The implementation is registered in META-INF/services/<interface-fqcn>
  4. At runtime, TnsAI 0.14.0 (TnsAI@b021c635, TAN-2903) discovers implementations through com.tnsai.spi.SpiLoader, which caches an immutable snapshot per (serviceType, thread-context classloader). Maven Central 0.13.0 called ServiceLoader.load() on every lookup; 0.14.1 uses the cached loader.
  5. Core uses the implementation without compile-time dependency on the module

Many SPI interfaces also use the Factory.discover() pattern where a nested Factory interface has a static discover() method that returns null when no implementation is on the classpath.

SpiLoader (0.14.0)

com.tnsai.spi.SpiLoader (@since 0.14.0) is the cached discovery helper used by Core and Intelligence hot paths:

  • SpiLoader.load(Class<T>) — immutable providers in ServiceLoader order
  • SpiLoader.findFirst(Class<T>) — first provider only, still cached
  • SpiLoader.invalidate(Class) / invalidateAll() — tests and redeploy

Cache keys and snapshots are weak so a parent-loaded SpiLoader does not pin an application classloader across redeploy.

Exceptions that stay on raw ServiceLoader:

  • MemoryStoreFactory — each store instance is agent-scoped mutable state, so every factory call must construct a fresh provider
  • EmbeddingFunctionRegistry and ContentExtractorRegistry — descriptor-first ServiceLoader.stream() so ambiguity fails before any provider constructor runs

RAG callers that reuse the cache: RetrievalSpi, ChatKnowledgeResolver, VectorStoreProviderRegistry, GraphStoreProviderRegistry, SourceLoaderRegistry, QueryExpanderRegistry, RerankerRegistry, TreeNavigatorRegistry.

Core SPI Interfaces

Types in com.tnsai.spi (0.13.0). Discovery is not uniform — some are ServiceLoader providers, some are markers, some are in-memory factories.

TypeHow you get it
CheckpointerProvider / CheckpointerFactoryCheckpointerFactory.getInstance() discovers providers
CognitiveModelCognitiveModel.bdi() / .reactive() factories
MessageBrokerMessageBroker.inMemory() (and module implementations)
ResilienceStrategyResilienceStrategy.builder() / .noOp()
HealthcheckableMarker — instanceof on wired LLM/memory/MCP components
TenantAwareMarker — instanceof when AgentBuilder.tenantId(...) is set
McpClientFactoryServiceLoaderMETA-INF/services/com.tnsai.spi.McpClientFactory in tnsai-mcp
ToolRegistryToolRegistry.inMemory()not META-INF; agent tools still use AgentBuilder

CheckpointerProvider

com.tnsai.spi.CheckpointerProvider -- pluggable storage backends for agent state checkpointing.

public interface CheckpointerProvider {
    String name();
    default String description() { return name() + " checkpointer"; }
    boolean isAvailable();
    Checkpointer create(Map<String, Object> config);
    default int priority() { return 0; }
    default Optional<String> validateSpec(Map<String, Object> config) { return Optional.empty(); }
}
MethodDescription
name()Unique provider name (e.g., "memory", "postgres", "sqlite")
isAvailable()Check if dependencies are present (e.g., JDBC driver)
create(Map<String, Object> config)Create a Checkpointer with config (url, path, username, etc.)
priority()Higher = preferred when multiple providers available
validateSpec(config)Validate config without creating -- returns error message or empty

Registration: META-INF/services/com.tnsai.spi.CheckpointerProvider

Example implementation:

public class PostgresCheckpointerProvider implements CheckpointerProvider {
    @Override
    public String name() { return "postgres"; }

    @Override
    public boolean isAvailable() {
        try {
            Class.forName("org.postgresql.Driver");
            return true;
        } catch (ClassNotFoundException e) {
            return false;
        }
    }

    @Override
    public Checkpointer create(Map<String, Object> config) {
        String url = (String) config.get("url");
        return new PostgreSQLCheckpointer(url);
    }
}

CheckpointerFactory

com.tnsai.spi.CheckpointerFactory is a singleton factory that discovers CheckpointerProvider implementations and provides a unified creation API.

CheckpointerFactory factory = CheckpointerFactory.getInstance();

// List providers
List<String> available = factory.availableProviders();

// Create by name
Checkpointer cp = factory.create("postgres", Map.of(
    "url", "jdbc:postgresql://localhost/mydb",
    "username", "user",
    "password", "pass"
));

// Auto-select best available
Checkpointer cp = factory.createBest(Map.of("path", "./data"));

// Default in-memory
Checkpointer cp = factory.createInMemory();

Built-in providers: "memory" (priority -100, always available) and "file" (priority -50, JSON files).

Tool registration is not a META-INF SPI

Agent tools are not discovered via ServiceLoader. There is no ToolProvider file under META-INF/services. Register tools per agent with AgentBuilder.builtInTools(BuiltInTool...), AgentBuilder.toolPojos(Object...), or AgentBuilder.dynamicTool(DynamicToolMethod) — see Tool Integration.

Each agent still builds a per-instance ToolMethodRegistry at AgentBuilder.build() from those explicit registrations. That registry is not a process-wide singleton.

LLMConfigurationSource is registered programmatically too. It lives in the integration module's SCOP package and supplies per-agent LLM overrides, but it has no META-INF/services entry and is not discovered by ServiceLoader. Register one on the bridge with SCOPBridge.llmConfigurationSource(...), using the folder(Path), environment() or noOp() factories — see External LLM Configuration.

com.tnsai.spi.ToolRegistry is a separate in-memory catalog API (ToolRegistry.inMemory()). It lives in the SPI package and is listed in package-info, but it has no META-INF/services/com.tnsai.spi.ToolRegistry registration. Do not treat it as the way AgentBuilder loads tools.

CognitiveModel

com.tnsai.spi.CognitiveModel -- unified API for agent cognitive architectures (BDI, Reactive, Hybrid).

public interface CognitiveModel {
    String getModelType();

    // Beliefs
    void addBelief(Belief belief);
    boolean removeBelief(String beliefContent);
    List<Belief> getBeliefs();
    List<Belief> queryBeliefs(String pattern);
    void clearBeliefs();

    // Goals
    void addGoal(Goal goal);
    boolean removeGoal(String goalId);
    List<Goal> getGoals();
    Optional<Goal> getTopGoal();

    // Intentions
    void addIntention(Intention intention);
    void completeIntention(String intentionId);
    List<Intention> getActiveIntentions();
    Optional<Intention> getCurrentIntention();

    // Reasoning cycle
    Optional<Action> reason(Map<String, Object> context);
    void reset();
    CognitiveState snapshot();
    void restore(CognitiveState state);

    // Factory methods
    static BDIModelBuilder bdi() { ... }
    static CognitiveModel reactive() { ... }
}

Inner records: Belief(content, confidence, timestamp, metadata), Goal(id, description, priority, status, parameters), Intention(id, goalId, planDescription, status, steps, currentStep), Action(type, description, parameters), CognitiveState(beliefs, goals, intentions, metadata).

CognitiveModel model = CognitiveModel.bdi()
    .withBelief("User prefers concise answers")
    .withGoal("Help the user effectively")
    .build();

model.addBelief(Belief.of("User is a developer", 0.9));
Optional<Action> next = model.reason(Map.of("input", "help me debug"));

MessageBroker

com.tnsai.spi.MessageBroker -- abstraction for agent-to-agent message passing (direct, pub/sub, request-reply, broadcast).

public interface MessageBroker {
    void send(String targetAgentId, Message message);
    void publish(String topic, Message message);
    String subscribe(String agentId, Consumer<Message> handler);
    String subscribeTopic(String topic, Consumer<Message> handler);
    void unsubscribe(String subscriptionId);
    CompletableFuture<Message> request(String targetAgentId, Message request);
    void broadcast(Message message);
    void close();
    static MessageBroker inMemory() { ... }
}

Message (record): id, from, to, topic, payload (Object), headers (Map), timestamp.

MessageBroker broker = MessageBroker.inMemory();

broker.subscribe("agent-1", msg -> System.out.println("Got: " + msg.payload()));
broker.publish("tasks", Message.of("Process data"));

CompletableFuture<Message> reply = broker.request("agent-1", Message.of("status?"));

ResilienceStrategy

com.tnsai.spi.ResilienceStrategy -- unified abstraction for resilience patterns (retry, circuit breaker, timeout, fallback).

public interface ResilienceStrategy {
    <T> T execute(Callable<T> operation) throws Exception;
    default <T> T executeUnchecked(Supplier<T> operation) { ... }
    default void execute(Runnable operation) throws Exception { ... }
    default ResilienceStrategy andThen(ResilienceStrategy after) { ... }
    default String name() { ... }
    default boolean isHealthy() { return true; }
    default void reset() { }
    static Builder builder() { ... }
    static ResilienceStrategy noOp() { ... }
}

The builder composes retry, circuit breaker, timeout, and fallback:

ResilienceStrategy strategy = ResilienceStrategy.builder()
    .retry(3, Duration.ofMillis(500))
    .circuitBreaker(5, Duration.ofSeconds(30))
    .timeout(Duration.ofSeconds(10))
    .fallback(() -> "default value")
    .build();

String result = strategy.execute(() -> riskyOperation());

Strategies can also be composed with andThen:

ResilienceStrategy combined = retryStrategy.andThen(timeoutStrategy);

Healthcheckable

com.tnsai.spi.Healthcheckable is a marker SPI, not a ServiceLoader catalog. Components that can answer a cheap reachability probe implement checkHealth(Duration) and return HealthStatus (never throw). Typical implementers: LLMClient, MemoryStore, MCP handshake adapters.

AgentBuilder.withReachabilityChecks(true) runs those probes at build() time. A component that does not implement the marker is skipped (instanceof Healthcheckable).

HealthStatus status = ((Healthcheckable) client).checkHealth(Duration.ofSeconds(2));
if (!status.reachable()) {
    // status.errorMessage() is log-safe — no credentials
}

TenantAware

com.tnsai.spi.TenantAware is an empty marker interface. Implement it on stores, tools, or MCP clients that honour AgentBuilder.tenantId(...) as a hard isolation boundary. Pre-flight validation (AGENT-V012) warns when a tenant id is set but a wired component is not TenantAware. The framework does not inspect how you isolate (row filter vs schema vs database).

McpClientFactory

com.tnsai.spi.McpClientFactory lets core's reachability pipeline probe an @MCPTool server URL without a compile-time dependency on tnsai-mcp. tnsai-mcp ships DefaultMcpClientFactory in META-INF/services/com.tnsai.spi.McpClientFactory. That is the only com.tnsai.spi.* service file in the 0.13.0 tree. Without tnsai-mcp on the classpath the validator stays silent.

public interface McpClientFactory {
    Healthcheckable probeFor(String serverUrl, Map<String, Object> config);
}

The returned Healthcheckable must follow the same cheap / idempotent / in-budget / never-throw contract.

Factory.discover() Pattern

Many SPI interfaces use an inner Factory interface with a static discover() method. Examples include:

  • EvalHandle.Factory.discover() — returns null if tnsai-quality is absent
  • FeedbackCollector.Factory.discover() — returns null if tnsai-intelligence is absent
  • ContextManagerHandle.Factory.discover() — returns null if tnsai-intelligence is absent
  • SecurityEnforcerHandle.Factory.discover() — returns null if tnsai-quality is absent; callers use SecurityEnforcerHandle.NOOP (there is no tnsai-security module)

This pattern allows core code to initialize with no-op implementations when optional modules are not on the classpath:

EvalHandle.Factory factory = EvalHandle.Factory.discover();
this.evalHandle = factory != null ? factory.create() : EvalHandle.NOOP;