Skip to content
tnsaijava agent framework

Roles

A Role defines what an agent can do. Each role has an identity (name, goal, domain) and discoverable actions. Safety constraints live on those actions (@ActionSpec.mustNever / mustAlways), not on the role itself. Roles generate the system prompt that instructs the LLM. Actions are methods annotated with @ActionSpec — they are discovered at runtime via reflection and routed to one of four executor types.

Creating Roles

There are two ways to create a role: programmatically with RoleBuilder, or by subclassing Role (optionally with @RoleSpec). RoleBuilder sets identity, LLM, guardrails, and programmatic actions. Builder-built roles do not carry safety constraints — for mustNever / mustAlways behavior, subclass Role and annotate the action methods.

With RoleBuilder (programmatic)

Use RoleBuilder when you want to define a role inline -- for example in tests, scripts, or when the role configuration is loaded dynamically at runtime. create() takes no arguments; set the name with .name(...).

package com.example.tnsai.docs;

import com.tnsai.llm.providers.AnthropicClient;
import com.tnsai.roles.Role;
import com.tnsai.roles.RoleBuilder;

public final class RolesBuilderExample {
    private RolesBuilderExample() {}

    public static Role createResearcher() {
        return RoleBuilder.create()
            .name("Researcher")
            .goal("Find and synthesize information from academic sources")
            .domain("academic-research")
            .llm(new AnthropicClient("claude-sonnet-4-20250514"))
            .build();
    }
}

With Annotations (declarative)

Use @RoleSpec when you want the role definition to live directly on the class. This is the preferred approach for production roles because identity, LLM config, and per-action safety constraints are visible at a glance.

package com.example.tnsai.docs;

import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.Goal;
import com.tnsai.annotations.LLMSpec;
import com.tnsai.annotations.RoleSpec;
import com.tnsai.enums.ActionType;
import com.tnsai.models.role.RoleIdentity;
import com.tnsai.roles.Role;

@RoleSpec(
    name = "Researcher",
    description = "Finds and synthesizes academic information",
    domains = {"academic", "research"},
    goals = {
        @Goal(
            name = "find_papers",
            condition = "relevant_papers_found",
            description = "Locate and synthesize relevant research"
        )
    },
    llm = @LLMSpec(provider = LLMSpec.Provider.ANTHROPIC, model = "claude-sonnet-4-20250514")
)
public final class RolesSpecExample extends Role {

    @Override
    public RoleIdentity getIdentity() {
        return new RoleIdentity("Researcher", "Find papers", "academic");
    }

    @ActionSpec(
        type = ActionType.LOCAL,
        description = "Search for academic papers on a topic",
        mustNever = {"fabricate references", "plagiarize content"},
        mustAlways = {"cite sources properly"}
    )
    public String searchPapers(String query) {
        return query;
    }
}

Actions

An Action is a method annotated with @ActionSpec on a Role class. Actions are routed to one of four executor types based on their ActionType:

TypeExecutorDescription
LOCALTypedActionExecutorDirect method invocation via reflection
WEB_SERVICEWebServiceExecutorHTTP REST API calls
LLMLLMRoleExecutorSingle-shot LLM call; tool dispatch via the agent's ToolMethodDispatcher
MCP_TOOLMcpToolExecutorModel Context Protocol tools

Defining Actions

Annotate any method on your Role class with @ActionSpec to expose it as an action. The type field tells the framework which executor handles the call.

@ActionSpec(
    description = "Search for academic papers on a topic",
    type = ActionType.LLM
)
public String searchPapers(String query) {
    return query;
}

ActionResult

When an action delegates to an external system (HTTP call, LLM tool, MCP tool), the framework executes the call and makes the raw result available as an ActionResult. There are two usage patterns:

Pure Delegate (Abstract, No Body)

If the method has no body (abstract or the framework handles it entirely), the framework executes the action and returns the result directly. No ActionResult parameter is needed:

@ActionSpec(
    type = ActionType.WEB_SERVICE,
    description = "Fetch a user by id",
    webService = @WebService(endpoint = "https://api.example.com/users/{id}")
)
public abstract Object getUser(String id);

Post-Process with ActionResult

Add an ActionResult parameter to receive the raw execution result and transform it before returning:

@ActionSpec(
    type = ActionType.WEB_SERVICE,
    description = "Fetch a record by id",
    webService = @WebService(endpoint = "https://api.example.com/data/{id}")
)
public Object getData(String id, ActionResult result) {
    // Return as-is
    return result;

    // Or extract a field
    Map<String, Object> json = result.asMap();
    return json.get("name");
}

ActionResult API

ActionResult wraps the raw value returned by the external system and provides convenience methods for common conversions like JSON parsing and type deserialization.

MethodReturn typeDescription
getValue()ObjectRaw result value
asString()StringValue as String (JSON-serialized if not already a String)
asMap()Map<String, Object>Value as Map (parsed from JSON if needed)
asList()List<Object>Value as List (parsed from JSON if needed)
asJson()JsonNodeJackson JsonNode for flexible JSON traversal
as(Class<T>)TDeserialize to a specific type
isNull()booleanTrue if the underlying value is null
isEmpty()booleanTrue if null, empty String, empty Map, or empty List

Example -- Transforming a Web Service Response

This example shows a common pattern: calling a weather API and reshaping the JSON response into a human-readable string before returning it to the agent.

@ActionSpec(
    type = ActionType.WEB_SERVICE,
    description = "Fetch a weather forecast",
    webService = @WebService(endpoint = "https://api.weather.com/forecast/{city}")
)
public String getForecast(String city, ActionResult result) {
    JsonNode json = result.asJson();
    String temp = json.path("main").path("temp").asText();
    String desc = json.path("weather").get(0).path("description").asText();
    return String.format("Temperature: %s, Conditions: %s", temp, desc);
}

Role Accessors

Once you have a Role instance, these methods let you inspect its identity, actions, and generated system prompt. Per-action safety constraints are on ActionMetadata, not on the role.

RoleIdentity identity = role.identity();
String name = role.getName();
String goal = role.getGoal();
String domain = role.getDomain();

List<ActionMetadata> actions = role.getActions();
int count = role.getActionCount();
boolean has = role.hasAction("searchPapers");
Optional<ActionMetadata> action = role.getAction("searchPapers");
String[] mustNever = action.map(ActionMetadata::getMustNever).orElse(new String[0]);
String[] mustAlways = action.map(ActionMetadata::getMustAlways).orElse(new String[0]);

String systemPrompt = role.getSystemPrompt();
String minimalPrompt = role.getMinimalPrompt();

BDI Model

Belief, Desire, Intention, and Capability remain as model types. They are not seeded on AgentBuilder. The fluent methods .belief(...), .desire(...), .intention(...), and .capability(...) were removed in 0.13.0 (TNS-541). Agent has no getBeliefs() / getDesires() / getIntentions(). The chat loop is LLM + tools, not a BDI interpreter.

AgentBuilder.identity(AgentIdentity) still exists. Optional BDI planning goes through the CognitiveModel.bdi() SPI — see SPI Reference.

Agent agent = AgentBuilder.create()
    .llm(llm)
    .role(role)
    .identity(new AgentIdentity("ResearchBot", "AI Research Assistant"))
    .principal(principal)
    .liabilitySink(sink)
    .authorityScope(scope)
    .build();
ConceptClassPurpose
BeliefBeliefWhat the agent knows (key-value pairs)
DesireDesireWhat the agent wants to achieve (goals)
IntentionIntentionHow the agent plans to act (committed plans)
CapabilityCapabilityWhat the agent can do (skills)
PlanPlanStructured plan for achieving desires