# Tutorial: Declarative Action RAG

Declare a corpus with `@Knowledge` on the Role, then name it from the action
that should be grounded. TnsAI loads the corpus lazily, retrieves matching
documents before the action executor runs, and adds the formatted context to
the request sent by an `ActionType.LLM` action.

The dependency, module, and behavior information on this page targets TnsAI
`0.18.6`. Sections headed **TnsAI 0.14.0** name when those additions landed,
and they remain in the current artifacts.

## Prerequisites

- [TnsAI 0.18.6](../start/install.md)
- `tnsai-core`, `tnsai-llm`, and `tnsai-rag` on the runtime classpath
- A directory containing UTF-8 text files
- An LLM provider configured for the example, which reads `OPENAI_API_KEY`
- Java parameter metadata enabled in your build (`-parameters`)

`tnsai-core` owns the annotations and the optional `RetrievalSpi` contract.
`tnsai-rag` supplies the service implementation, source loader, indexes,
strategies, and context assembly. Without `tnsai-rag` on the classpath,
action dispatch continues without retrieval.

## A complete action-RAG example

Save the following as `ResearchAgentDemo.java`. The `research/` directory is
resolved from the JVM working directory. Put one or more `.md` or `.txt` files
there before running the example.

TnsAI discovers action argument names through Java reflection. Enable parameter
metadata in the consuming Maven project so the invocation key `question`
matches the method parameter:

```xml
<properties>
  <maven.compiler.parameters>true</maven.compiler.parameters>
</properties>
```

The equivalent direct compiler flag is `javac -parameters`.

<!-- java-contract: src/main/java/ResearchAgentDemo.java -->
```java
import com.tnsai.accountability.AuthorityScope;
import com.tnsai.actions.ActionExecutor;
import com.tnsai.agents.Agent;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.AgentSpec;
import com.tnsai.annotations.Knowledge;
import com.tnsai.annotations.RoleSpec;
import com.tnsai.annotations.Search;
import com.tnsai.enums.ActionType;
import com.tnsai.identity.AgentDescriptor;
import com.tnsai.identity.AgentPrincipal;
import com.tnsai.identity.LocalIdentityProvider;
import com.tnsai.llm.LLMClient;
import com.tnsai.llm.providers.OpenAIClient;
import com.tnsai.models.role.RoleIdentity;
import com.tnsai.roles.Role;

import java.time.Duration;
import java.util.List;
import java.util.Map;

public final class ResearchAgentDemo {

    @RoleSpec(
        name = "Researcher",
        description = "Answers questions from a local research corpus",
        knowledge = @Knowledge(
            name = "research-corpus",
            path = "research"
        )
    )
    public static final class ResearchRole extends Role {

        @Override
        public RoleIdentity getIdentity() {
            return new RoleIdentity(
                "Researcher",
                "Answer questions from trusted documents",
                "research"
            );
        }

        @ActionSpec(
            type = ActionType.LLM,
            description = "Answer with evidence from the research corpus",
            knowledge = @Knowledge(
                name = "research-corpus",
                search = @Search(
                    strategy = Search.Strategy.HYBRID,
                    topK = 5,
                    minScore = 0.0,
                    contextWindow = 2_000,
                    contextFormat = "[Source: ${source}]\n${content}\n\n",
                    onFailure = Search.FallbackAction.FAIL
                )
            )
        )
        public String answer(String question) {
            // ActionType.LLM returns the LLM executor's result. This method
            // body is not invoked unless an ActionResult parameter is added.
            return question;
        }
    }

    @AgentSpec(description = "Retrieval-grounded research assistant")
    public static final class ResearchAgent extends Agent {

        private final ResearchRole role = new ResearchRole();

        public ResearchAgent(AgentPrincipal principal, AuthorityScope scope) {
            super("research-agent", null, null, wired(principal, scope));
        }

        private static ActionExecutor wired(AgentPrincipal principal, AuthorityScope scope) {
            ActionExecutor executor = new ActionExecutor();
            executor.setPrincipal(principal);
            executor.setDefaultScope(scope);
            return executor;
        }

        @Override
        protected LLMClient getLLM() {
            return new OpenAIClient("gpt-4o-mini");
        }

        @Override
        protected List<Role> getRoles() {
            return List.of(role);
        }
    }

    public static void main(String[] args) {
        AgentPrincipal principal = new LocalIdentityProvider().issue(
            AgentDescriptor.builder()
                .agentClass(ResearchAgent.class.getName())
                .systemPrompt("Answer questions from a local research corpus.")
                .toolNames(List.of("answer"))
                .model("openai:gpt-4o-mini")
                .build());
        Agent agent = new ResearchAgent(
            principal, AuthorityScope.unrestricted(Duration.ofHours(1)));
        Object answer = agent.executeAction(
            "answer",
            Map.of("question", "How does retrieval ground this answer?")
        );
        System.out.println(answer);
    }
}
```

`@Knowledge.type` defaults to `FILE` and `use` defaults to `SEARCH`, so the
example sets neither. The Role declares the source once. The action names it
and sets only its `@Search` settings. `onFailure = FAIL` prevents a retrieval
transport failure from silently producing an ungrounded LLM request. An empty
corpus or a query with no matching documents is not a transport failure.
Applications that require evidence for every answer should also enforce a
positive retrieved-document count in their execution policy.

### Source loading failures and empty results (0.17.3)

The bundled FILE and HTTP/HTTPS loaders distinguish an unsuccessful load
from a source that was read successfully but produced no documents. A declared
FILE source that is missing or unreadable, or an HTTP request that fails, raises
`RetrievalException` with reason `SOURCE_LOADING`. A successfully read empty
source and a successful retrieval with no matching documents are valid empty
results, and they do not trigger `onFailure`.

For a source-loading failure, `onFailure = FAIL` propagates the typed failure.
`onFailure = CONTINUE` skips retrieval context and records a retrieved-document
count of zero, allowing the action to continue without retrieved evidence.
Choose `CONTINUE` only when that ungrounded continuation is acceptable to your
application. Invalid source configuration, including an invalid format, remains
a `CONFIGURATION` failure above the
`onFailure` policy, and `CONTINUE` does not hide it. Cancellation also continues
to propagate.

## How the context reaches the LLM

For the `answer` dispatch, the runtime follows this path:

1. It validates and sanitizes the action parameters.
2. `RetrievalSpi` selects the first declared `String` parameter as the query.
3. It resolves `HYBRID` for `research-corpus`, searches the indexes, fuses the
   vector and keyword ranks, and applies score and result limits.
4. It deduplicates results by default, optionally reranks them, and caches the
   resulting document list by default.
5. It renders the documents with `contextFormat` inside `contextWindow` and
   writes the result to the action context.
6. `LLMRoleExecutor` XML-escapes the rendered block inside the canonical
   `<tnsai-memory>` boundary, adds the untrusted-context disclosure to the
   system prompt, and sends the request to the configured client.

The generated request has this shape:

```text
Answer with evidence from the research corpus

Retrieved context:
<tnsai-memory>
<context>[Source: /absolute/path/research/retrieval.md]
Retrieval runs before the LLM executor.</context>
</tnsai-memory>

Input:
question: How does retrieval ground this answer?
```

The action context carries five reserved keys together after retrieval:

| Key | Value |
|---|---|
| `_rag_context` | Rendered, token-bounded context text |
| `_rag_document_count` | Number of documents included in that text |
| `_rag_stale_fallback` | Whether `USE_CACHE` supplied expired results |
| `_rag_context_truncated` | Whether `contextWindow` shortened or dropped results |
| `_rag_knowledge_usage` | What each named source gave the action, one `KnowledgeUsage` row per enabled source |

The LLM executor splices context only when the document count is positive and
the rendered text is non-blank. A custom executor can inspect the same keys.
The annotated action method itself does not receive the context map.

The default LLM executor labels stale-cache evidence and context-window
truncation in the heading. If truncation removes every matched document,
dispatch fails with a validation error before the LLM is called.

## TnsAI 0.14.0: per-dispatch action context

Action dispatch can carry the same request-local isolation and lifecycle data
as chat retrieval. Build a fresh `ChatRetrievalContext` when a request needs an
explicit tenant/session scope, cancellation token, deadline, run or episode
identity, source revision, or exact document metadata filters, then pass it to
an action overload:

```java
CancellationToken cancellation = CancellationToken.create();
ChatRetrievalContext requestContext = ChatRetrievalContext.builder()
    .scope("tenant-a", "conversation-42")
    .runId("run-7")
    .currentEpisode("episode-3")
    .cancellationToken(cancellation)
    .timeout(Duration.ofSeconds(3))
    .metadataFilters(Map.of("tier", "approved"))
    .build();

ActionResponse response = agent.executeAction(
    ActionRequest.of(
        "answer",
        Map.of("question", "How does retrieval ground this answer?")
    ),
    requestContext
);
```

The same context-bearing surface exists for untyped action dispatch, typed and
untyped role action dispatch, tool-streaming, pre-rendered retrieval streaming,
and consumer-based event chat. The session-publisher event overload does not
take an application context. `Agent` fills missing scope and agent-name values
from the active entry context. `DefaultRetrievalSpi` carries the context into
the unified engine and adds canonical owner/run/agent/simulation/episode/source
and binding-revision metadata.

From 0.18.6 the context also carries a turn's raw message, its conversation
and its source locations as components, set with the builder's
`turnMessage(String)`, `conversationId(String)` and `sourcePaths(Map)` and
empty when left out. `ChatKnowledgeBinding.retrieve` copies the three into
the `RetrievalRequest`, searches the turn message instead of the rendered
message when there is one, and reads the chat's `DIRECT` sources from the
locations `sourcePaths` names. A cached result is kept apart per turn
message, conversation and location. Code that calls the canonical
constructor of `ChatRetrievalContext` or `RetrievalRequest`, or matches
either with a record pattern, has to add the three components.

The reserved action keys above remain the compatibility boundary between
retrieval and action executors. They are cleared at the start of each dispatch
before the optional SPI runs, so reusing a caller map cannot leak prior RAG
evidence. `LLMRoleExecutor` applies the canonical fence and disclosure. Custom
executors must treat `_rag_context` as untrusted data themselves.

`ChatKnowledgeBinding` is a separate concern: it owns a chat knowledge source
and its invalidation/close lifecycle. `ChatRetrievalContext` is request-local
and belongs to one chat, stream, event, or action call. Cancellation remains
cooperative, and application retrievers must enforce the absolute deadline in
their own backend I/O when a strict wall-clock bound is required.

## Source declarations

The source members of `@Knowledge` describe ingestion, not query-time ranking.
They are set where the source is declared, on `@RoleSpec` or `@AgentSpec`,
and an action's entry names the source and may set only `use`, `maxTokens`, and
`search`.

| Member | Default | Behavior |
|---|---:|---|
| `name` | required | Exact, case-sensitive source identifier that actions name |
| `type` | `FILE` | `FILE` and `URL` have bundled loaders, and `DATABASE` needs an application `SourceLoader` |
| `format` | `AUTO` | Selects AUTO detection or a strict per-file format |
| `path` | `""` | File, directory, or URL read by the loader |
| `connection` | `""` | Available to an application-provided `DATABASE` loader |
| `query` | `""` | Available to an application-provided `DATABASE` loader |
| `enabled` | `true` | Disabled sources are not loaded and cannot be named |
| `include` | empty | Includes matching FILE paths before ingestion |
| `exclude` | empty | Excludes matching FILE paths before ingestion |

The remaining members (`select`, `unit`, `chunkContext`,
`chunkContextModel`, `use`, `maxTokens`) are listed in the
[RAG field reference](../rag/index.md#knowledge-fields).
`MEMORY` is an application-supplied chat corpus and `GRAPH` is a graph-store
handle for actions, and neither is a loaded file corpus.

The bundled file loader walks directories recursively, applies `include` /
`exclude`, and runs the canonical FILE ingestion service. Small documents
stay intact, and larger documents split into stable, format-aware chunks.

A loader runs when the Role binding is created and materializes documents into
local indexes. A remote index queried on every request belongs behind
`RetrievalEngineProvider`, not `SourceLoader`.

Bindings are cached by Role class and source configuration for the JVM
lifetime. When a file corpus changes, invalidate it so the next dispatch
re-ingests the source:

```java
RoleRagBinding.invalidate(ResearchRole.class);
```

`cacheTTL` controls query-result caching against the existing binding and does
not refresh files.

## The `@Search` surface

`@Search` is the `search` member of a `SEARCH` entry. Every member is an
array whose empty default means "not given". Write a single value as usual
(`topK = 8`), and more than one value fails at startup. An action's own
`@Knowledge(name = ..., search = @Search(...))` replaces the declared
`@Search` as a whole, so repeat every declared member the action should keep.
Several `SEARCH` sources in one scope run through one retrieval engine, so
their members merge field by field: a member set by one source applies to all
of them, and two sources setting it to different values fail at startup. A
scope is a class's whole declaration (all `SEARCH` entries of its
`@RoleSpec` and `@AgentSpec` together) and, again, the entries one action names
after its overrides. Sources that need different values belong on the
actions' entries, not on the declaration.

| Member | Unset means | Behavior |
|---|---:|---|
| `strategy` | `SEMANTIC` | Chooses one of the eight live strategy paths |
| `queryParam` | positional | Names the action argument used as the query, and unset keeps first-`String` order |
| `rerank` | `false` | Runs a discovered `Reranker` after retrieval and deduplication |
| `rerankerModel` | none | Required provider-owned identifier when reranking is enabled |
| `contextWindow` | `4000` | Token budget for the complete rendered context |
| `topK` | `5` | Maximum results requested from the selected strategy |
| `topN` | `3` | Maximum results retained after reranking |
| `minScore` | `0.5` | Filters results below a score from `0.0` to `1.0` |
| `deduplicate` | `true` | Removes similar results after fusion and before reranking |
| `dedupeThreshold` | `0.9` | Similarity threshold used when deduplication is enabled |
| `queryExpansion` | `NONE` | Selects synonym, multi-query, HyDE, or step-back expansion |
| `queryExpansionModel` | none | Required provider-owned identifier when expansion is enabled |
| `navigatorModel` | none | Required `TreeNavigator` identifier when `strategy` is `REASONING` |
| `expandedQueries` | `3` | Maximum generated variants in addition to the original query |
| `includeSpec` | `true` | Controls whether source and score placeholders expose metadata |
| `contextFormat` | source + content | Supports `${content}`, `${source}`, and `${score}` only |
| `cache` | `true` | Enables the bounded per-Role retrieval-result cache |
| `cacheTTL` | `300` | Fresh cache lifetime in seconds |
| `maxStaleness` | `3600` | Extra stale lifetime available to `USE_CACHE`, and `-1` is unbounded |
| `onFailure` | `CONTINUE` | `CONTINUE`, `FAIL`, `USE_CACHE`, or one keyword `RETRY_SIMPLE` |

Configuration errors, including unknown source names or missing providers, are
not softened by `onFailure`. Provider or retrieval failures that occur while
serving a valid configuration follow the selected failure policy.

### Strategy requirements

All eight `Search.Strategy` values are live, and none silently falls back to
`SEMANTIC`:

| Strategy | Runtime path and requirements |
|---|---|
| `SEMANTIC` | Vector similarity over the binding's vector store |
| `KEYWORD` | BM25 keyword retrieval |
| `HYBRID` | Reciprocal-rank fusion of semantic and keyword results |
| `GRAPH` | Uses a bundled FILE-corpus, KnowledgeTools, or Neo4j adapter |
| `MULTI_QUERY` | Expands through a discovered `QueryExpander`, then retrieves semantically and fuses results |
| `HIERARCHICAL` | Expands vector hits through valid parent metadata supplied by the loader |
| `TEMPORAL` | Applies time decay using ISO-8601 timestamp metadata supplied by the loader |
| `REASONING` | Descends a valid document hierarchy through an installed `TreeNavigator` serving `navigatorModel` |

TnsAI does not bundle a reranker, query-expansion model provider, or tree
navigator. Enabling `rerank`, a non-`NONE` `queryExpansion`, `MULTI_QUERY`, or
`REASONING` therefore requires an installed provider that serves the configured
model identifier.

### Named query and reasoning configuration

`queryParam` selects a named action argument and requires consumer classes
compiled with Java's `-parameters` flag. `navigatorModel` selects the model used
by the installed tree navigator for `REASONING`. A blank identifier is a
configuration error, while an unmatched identifier is provider unavailable.
Both cases fail before `onFailure` applies.

### Effective retrieval order

The action-level document pipeline is:

```text
query selection
  -> optional query expansion
  -> base strategy retrieval and multi-query fusion
  -> deduplication
  -> optional reranking and topN
  -> optional result-cache storage/reuse
  -> context formatting and contextWindow bounding
  -> LLM prompt splice
```

`contextFormat`, `includeSpec`, and `contextWindow` affect rendered prompt text,
not which documents are stored in the result cache.

## Action RAG and chat RAG are different contracts

Action knowledge grounds a named action dispatched through `executeAction`.
The action sees only the entries its `@ActionSpec(knowledge = ...)` names,
with their `@Search` settings. It is valid on `LLM` actions, where the
context is spliced into the prompt, and on `LOCAL` actions, where it is pulled
before the body runs. Any other action type that names knowledge fails at
startup.

Chat knowledge is declared on the `Agent` class instead. Every enabled
`SEARCH` or `DIRECT` `@AgentSpec` entry grounds the agent's conversation path,
and chat needs no action to name it. `SEQUENTIAL` and `GRAPH` entries reach only the actions
that name them:

```java
import com.tnsai.agents.Agent;
import com.tnsai.annotations.AgentSpec;
import com.tnsai.annotations.Knowledge;
import com.tnsai.annotations.Search;

@AgentSpec(
    description = "Support chat",
    knowledge = @Knowledge(name = "support-docs", path = "support",
                           search = @Search(topK = 5)))
public abstract class SupportAgent extends Agent {}
```

A Role's entries do not ground an ordinary `agent.chat(...)` turn. Chat sees
only the `@AgentSpec` entries. See
[RAG](../rag/index.md#three-ways-to-ground-chat) for the chat
paths.

## TnsAI 0.14.0: declarative embedding provider

Declarative Role bindings discover one process-wide `EmbeddingFunction` from
the application classpath. A provider can call a local model or remote
embedding service, and this small class only shows the installable contract:

<!-- java-contract: src/main/java/com/example/tnsai/docs/ApplicationEmbeddingProvider.java -->
```java
package com.example.tnsai.docs;

import com.tnsai.memory.advanced.EmbeddingFunction;

public final class ApplicationEmbeddingProvider implements EmbeddingFunction {
    public ApplicationEmbeddingProvider() {}

    @Override
    public float[] embed(String text) {
        float length = text == null ? 0.0f : text.length();
        return new float[] {length, 1.0f};
    }
}
```

Register that class in this exact application resource:

```text
# src/main/resources/META-INF/services/com.tnsai.memory.advanced.EmbeddingFunction
com.example.tnsai.docs.ApplicationEmbeddingProvider
```

Zero installed providers keep the deterministic 128-dimensional hash fallback.
Exactly one provider is cached process-wide and shared by vector-backed
declarative strategies and embedding-based deduplication. Multiple providers
are a configuration error, and the runtime never selects one by classpath order.

Provider calls admit one request at a time with no wait queue. Applications
must control concurrency and configure backend-native timeouts. Vectors must be
non-null, non-empty, finite, no wider than 16,384 values, and keep one stable
dimension. The runtime snapshots provider-owned buffers before returning them.
See [RAG strategies](../rag/retrieval-strategies.md#tnsai-0140-declarative-embedding-provider)
for the exact failure and validation contract.

The lower-level programmatic strategy API remains separate: callers construct
a `VectorMemoryStore` with an explicit `EmbeddingFunction`, then pass that store
to `VectorRAGStrategy` or `HybridRAGStrategy`. That store does not use the
declarative ServiceLoader selection.

## TnsAI 0.14.0: named retrieval queries

Use `@Search(queryParam = "question")` or
`RetrievalConfig.builder().queryParam("question")` when an action has multiple
String parameters. Consumer classes must be compiled with Java's `-parameters`
flag. Otherwise reflection exposes names such as `arg0` and named binding is a
configuration error before `onFailure` applies. Leaving `queryParam` unset
preserves the first-String positional rule. A `queryParam` set where the
source is declared applies to every action that names that source.

## Runtime references

- `@Knowledge`, `@Search`, and `@ActionSpec`:
  `tnsai-core/src/main/java/com/tnsai/annotations/`
- `RetrievalConfig` and `RetrievalSpi`:
  `tnsai-core/src/main/java/com/tnsai/rag/`
- `LLMRoleExecutor` prompt splice:
  `tnsai-core/src/main/java/com/tnsai/actions/executors/LLMRoleExecutor.java`
- `DefaultRetrievalSpi`, `RoleRagBinding`, and `LocalFileSourceLoader`:
  `tnsai-rag/src/main/java/com/tnsai/intelligence/rag/binding/`
- Programmatic strategies:
  `tnsai-rag/src/main/java/com/tnsai/intelligence/rag/`
