TnsAI

Tutorial: Reusable Capabilities

Build an editorial agent that can summarise, translate, and classify sentiment — without writing a single body for those action methods. This tutorial walks through the @Capability pattern, showing composition, override, and the common mistakes the framework prevents.

Goal

At the end of the tutorial you will have:

  1. Three reusable capability interfaces (Summarizer, Translator, SentimentClassifier) that can be shared across any number of roles.
  2. An EditorRole that composes all three — zero method bodies for dispatched actions.
  3. A StrictEditor variant that overrides one capability with a deterministic local implementation — demonstrating the role-wins dedupe rule.
  4. A minimal test exercising the agent end-to-end.

Total work: about 40 lines of interface code, 15 lines of role code, 15 lines of test.

Prerequisites

Step 1 — Define the Capabilities

Each capability is an interface annotated @Capability. The methods carry their usual @ActionSpec metadata; the default bodies throw Actions.dispatchedByFramework() so bypass calls fail loudly instead of silently returning null.

Per-action LLM overrides (system prompt, temperature) live directly on @ActionSpec — no nested annotation needed.

Summarizer

package com.example.tutorial.capabilities;

import com.tnsai.actions.Actions;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.capabilities.Capability;
import com.tnsai.enums.ActionType;

@Capability
public interface Summarizer {

    @ActionSpec(
        type = ActionType.LLM,
        description = "Summarise the given text in one short paragraph of at most 60 words.",
        llmSystemPrompt = "You are a concise summariser. Output plain text only, no preamble.",
        llmTemperature = 0.2f
    )
    default String summarize(String text) {
        throw Actions.dispatchedByFramework();
    }
}

Translator

package com.example.tutorial.capabilities;

import com.tnsai.actions.Actions;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.capabilities.Capability;
import com.tnsai.enums.ActionType;

@Capability
public interface Translator {

    @ActionSpec(
        type = ActionType.LLM,
        description = "Translate the given text to the target language, preserving tone.",
        llmSystemPrompt = "You are a precise translator. Output only the translation.",
        llmTemperature = 0.1f
    )
    default String translate(String text, String targetLanguage) {
        throw Actions.dispatchedByFramework();
    }
}

SentimentClassifier

package com.example.tutorial.capabilities;

import com.tnsai.actions.Actions;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.capabilities.Capability;
import com.tnsai.enums.ActionType;

@Capability
public interface SentimentClassifier {

    @ActionSpec(
        type = ActionType.LLM,
        description = "Classify the sentiment of the input as POSITIVE, NEGATIVE, or NEUTRAL.",
        llmSystemPrompt = "Output exactly one of: POSITIVE, NEGATIVE, NEUTRAL. Nothing else.",
        llmTemperature = 0.0f
    )
    default String classifySentiment(String text) {
        throw Actions.dispatchedByFramework();
    }
}

Each capability is now a first-class reusable contract. Any role in your codebase can gain these abilities by implements-ing the interface.

Step 2 — Compose Them Onto a Role

The role class is where agent state lives (memory, history, lifecycle). Capabilities are added by implementing their interfaces — no method bodies needed:

package com.example.tutorial.roles;

import com.example.tutorial.capabilities.Summarizer;
import com.example.tutorial.capabilities.Translator;
import com.example.tutorial.capabilities.SentimentClassifier;
import com.tnsai.annotations.RoleIdentity;
import com.tnsai.models.role.Responsibility;
import com.tnsai.roles.Role;

import java.util.List;

@RoleIdentity(
    name = "Editor",
    goal = "Produce clean, readable articles in any target language"
)
public class EditorRole extends Role implements Summarizer, Translator, SentimentClassifier {

    @Override
    public List<Responsibility> getResponsibilities() {
        return List.of(
            new Responsibility("Condense source material", "High"),
            new Responsibility("Render outputs in the requested language", "High"),
            new Responsibility("Gauge reader sentiment on drafts", "Medium")
        );
    }

    // Agent state + lifecycle methods go here. No capability bodies — inherited.
}

ActionDiscovery walks the interface chain at role initialisation, picks up the three @ActionSpec methods from the capability interfaces, and registers them as the role's actions. The LLM sees all three as tools it can call; dispatch flows through ActionExecutorLLMRoleExecutor exactly as it would for a concrete @ActionSpec method.

Step 3 — Wire Into an Agent

import com.example.tutorial.roles.EditorRole;
import com.tnsai.agents.Agent;
import com.tnsai.agents.AgentBuilder;
import com.tnsai.llm.LLMClientFactory;
import com.tnsai.roles.Role;

public class TutorialMain {

    public static void main(String[] args) {
        Agent editor = AgentBuilder.create()
            .llm(LLMClientFactory.create("openai", "gpt-4o", 0.3f))
            .role(Role.create(EditorRole.class))
            .build();

        // The LLM now has three tools available: summarize, translate, classifySentiment.
        String reply = editor.chat(
            "Please summarise this in one line, then translate the summary to Turkish: "
                + "The framework pattern eliminates boilerplate by moving dispatched method "
                + "contracts into reusable interfaces. Roles compose them without bodies."
        );

        System.out.println(reply);
    }
}

Run it; the LLM picks summarize, then picks translate, and produces the combined output. Your codebase contains zero lines of return null; — the contracts are expressed once, on the capability interfaces.

Step 4 — Override When You Need Deterministic Behaviour

A role that implements a capability can also declare its own version of the method. When that happens, the role's declaration wins; the capability's default is skipped. Use this when a specific role needs deterministic, non-LLM behaviour for one of the contracted actions:

package com.example.tutorial.roles;

import com.example.tutorial.capabilities.Summarizer;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.RoleIdentity;
import com.tnsai.enums.ActionType;
import com.tnsai.models.role.Responsibility;
import com.tnsai.roles.Role;

import java.util.List;

@RoleIdentity(
    name = "StrictEditor",
    goal = "Truncate-first summarise (deterministic, no LLM)"
)
public class StrictEditor extends Role implements Summarizer {

    @Override
    public List<Responsibility> getResponsibilities() {
        return List.of(new Responsibility("Produce identical output for identical input", "High"));
    }

    // Overrides Summarizer.summarize — type flips from LLM to LOCAL.
    @Override
    @ActionSpec(
        type = ActionType.LOCAL,
        description = "Truncate to the first 60 characters (no LLM, reproducible)."
    )
    public String summarize(String text) {
        return text.length() <= 60 ? text : text.substring(0, 60) + "...";
    }
}

When ActionDiscovery scans StrictEditor, it records the class-declared summarize signature on the first pass, then skips Summarizer.summarize on the capability-interface pass. Exactly one summarize action ends up in the role's metadata, and its ActionType is LOCAL rather than LLM. The LLM still sees an action called summarize; invoking it just runs the deterministic body instead of hitting the model.

Step 5 — Test

import com.example.tutorial.roles.EditorRole;
import com.tnsai.actions.ActionDiscovery;
import com.tnsai.metadata.ActionMetadata;
import com.tnsai.metadata.DiscoveredRoleActions;
import org.junit.jupiter.api.Test;

import java.util.Set;
import java.util.stream.Collectors;

import static org.junit.jupiter.api.Assertions.*;

class EditorRoleCapabilityTest {

    @Test
    void editorGainsAllThreeCapabilityActions() {
        DiscoveredRoleActions discovered = ActionDiscovery.discoverActions(EditorRole.class);

        Set<String> actionNames = discovered.getActions().stream()
            .map(ActionMetadata::getName)
            .collect(Collectors.toSet());

        assertEquals(
            Set.of("summarize", "translate", "classifySentiment"),
            actionNames,
            "Role must gain actions from every @Capability interface it implements"
        );
    }
}

ActionDiscovery.discoverActions is synchronous and pure — it works at unit-test time without an LLM client. A passing test here proves the composition works before you ever hit the network.

Common Mistakes the Framework Catches

Two misuses of @Capability fail fast at discovery time with an IllegalStateException. Knowing them up front saves a debugging round:

  1. Abstract method (no default body) — a capability method without a default forces every adopter to write a body, defeating the whole point. The error message names the offending interface and method, and points to default { throw Actions.dispatchedByFramework(); } as the fix.
  2. ActionResult parameter on a capability method — capabilities are pure dispatch. If a method needs post-processing on the LLM's raw response, keep that method on the concrete role class (the legacy pattern) rather than on the capability interface.

The validator message is explicit in both cases; you don't have to guess what went wrong.

What Not to Turn Into a Capability

  • ActionType.LOCAL methods — they have real bodies the framework invokes via reflection. They don't suffer from the return null problem.
  • Methods with ActionResult parameters — rejected by the validator by design.
  • One-off methods used by a single role — extracting a capability interface for one consumer adds indirection without reuse benefit. Inline is fine.
  • Capabilities — concept reference, validation rules, migration table
  • Action System — how ActionExecutor routes dispatched calls
  • Roles — declaring roles, @RoleIdentity, responsibilities

Implementation References

  • com.tnsai.capabilities.Capability — marker annotation
  • com.tnsai.actions.Actions.dispatchedByFramework() — exception-returning helper
  • com.tnsai.actions.ActionDiscovery — two-pass discovery (class methods, then @Capability interface methods)

On this page