TnsAI

Server Advanced Features

Declarative hooks, agent factory, idle shutdown management, and the RAG pipeline (indexing, chunking, retrieval).

Declarative Hook System

The com.tnsai.server.hooks package provides a YAML-driven hook system that attaches lifecycle callbacks to agents without writing Java code.

Architecture Overview

hooks.yaml --> DeclarativeHookRegistry --> ToolCallListener on Agent
                     |                          |
                     |                    PRE_TOOL_USE / POST_TOOL_USE
                     |
               DeclarativeHookExecutor
                     |
              COMMAND (shell) / LOG (message)

HookEvent

Lifecycle events that hooks can listen for:

EventDescription
PRE_TOOL_USEBefore a tool executes
POST_TOOL_USEAfter a tool executes
ON_STARTWhen the agent starts
ON_STOPWhen the agent stops
ON_ERRORWhen an error occurs

Parsing supports both camelCase (preToolUse) and UPPER_SNAKE (PRE_TOOL_USE) from YAML.

HookActionType

TypeDescription
COMMANDExecute a shell command
LOGLog a message

HookAction

Record defining what to execute when a hook fires.

// Factory methods
HookAction cmd = HookAction.command("echo 'tool finished'");
HookAction log = HookAction.log("Tool ${tool} is about to execute");

Fields: type (HookActionType), command (for COMMAND), message (for LOG). Supports ${variable} placeholders that are substituted at execution time.

HookMatcher

Matches tool names against patterns. Case-insensitive using Locale.ROOT.

// Exact match
HookMatcher exact = new HookMatcher("shell");
exact.matches("shell");    // true
exact.matches("Shell");    // true (case-insensitive)

// Glob wildcard
HookMatcher glob = new HookMatcher("file_*");
glob.matches("file_read");  // true
glob.matches("file_write"); // true
glob.matches("shell");      // false

// Match all tools
HookMatcher all = HookMatcher.ALL; // pattern "*"

HookConfig

A single hook definition, typically parsed from YAML.

// Record: HookConfig(event, matcher, action)
HookConfig config = new HookConfig(
    HookEvent.PRE_TOOL_USE,
    new HookMatcher("file_*"),
    HookAction.log("Tool ${tool} is about to execute")
);

HookExecutor Interface

@FunctionalInterface
public interface HookExecutor {
    void execute(HookAction action, Map<String, String> context);
}

DeclarativeHookExecutor

Default executor that runs shell commands (with 30-second timeout) and logs messages. All execution is best-effort: failures are logged but never propagate.

Variable substitution replaces ${key} placeholders with values from the context map.

DeclarativeHookRegistry

Parses YAML hook definitions and registers them as runtime listeners on agents.

YAML schema:

hooks:
  - event: preToolUse
    matcher: "file_*"
    action:
      type: log
      message: "Tool ${tool} is about to execute"

  - event: postToolUse
    matcher: "shell"
    action:
      type: command
      command: "echo 'shell tool finished'"

  - event: onStart
    action:
      type: log
      message: "Agent started"

Loading and applying hooks:

// Parse from YAML
InputStream yaml = new FileInputStream("hooks.yaml");
DeclarativeHookRegistry registry = DeclarativeHookRegistry.fromYaml(yaml);

// Parse with custom executor
DeclarativeHookRegistry registry = DeclarativeHookRegistry.fromYaml(yaml, customExecutor);

// Programmatic creation
DeclarativeHookRegistry registry = DeclarativeHookRegistry.of(hookConfigs);

// Apply to an agent (registers ToolCallListener + fires ON_START)
registry.applyTo(agent);

Manual event firing:

// Fire a generic event
registry.fireEvent(HookEvent.ON_START, Map.of("agent", agentId));

// Fire a tool-specific event (checks HookMatcher against tool name)
registry.fireToolEvent(HookEvent.PRE_TOOL_USE, "shell",
    Map.of("event", "preToolUse"));

Querying hooks:

List<HookConfig> all = registry.getHooks();
List<HookConfig> preHooks = registry.getHooksForEvent(HookEvent.PRE_TOOL_USE);

When applied to an agent via applyTo(), the registry creates a ToolCallListener that:

  • Fires PRE_TOOL_USE hooks in onToolCallStart
  • Fires POST_TOOL_USE hooks in onToolCallComplete
  • Fires ON_ERROR hooks when success is false

Agent Factory

AgentFactory (com.tnsai.server.agent) creates agents for chat sessions with built-in tools and configurable LLM providers.

Built-in Tools

Every agent created by AgentFactory includes:

ToolDescription
ShellToolShell command execution with risk classification
FileReadToolRead file contents (auto-approved)
FileWriteToolWrite file contents (requires approval)

Creating Agents

// Setup
Supplier<LLMClient> llmSupplier = () -> new AnthropicClient("claude-sonnet-4-20250514");
AgentFactory factory = new AgentFactory(llmSupplier);

// Or with a custom working directory
AgentFactory factory = new AgentFactory(llmSupplier, Path.of("/project"));

// Simple creation (role name only)
Agent agent = factory.createAgent("agent-001", "assistant");

// With additional tools (e.g., MCP proxy tools)
Agent agent = factory.createAgent("agent-001", "developer", additionalTools);

// With provider and model override
Agent agent = factory.createAgent("agent-001", "developer",
    "ollama", "glm-5:cloud", additionalTools);

// Full configuration with custom goal and domain
Agent agent = factory.createAgent("agent-001", "developer",
    "ollama", "glm-5:cloud",
    "Write clean, tested code for the TnsAI framework",
    "tnsai-core",
    additionalTools);

// Multi-role agent from RoleDef list
Agent agent = factory.createAgent("agent-001", "assistant",
    "ollama", "glm-5:cloud", roleDefs, additionalTools);

Built-in Role Goals

RoleGoal
assistantHelp users by answering questions. Use tools to read files, execute commands, and write code.
developerWrite clean, well-tested code. Use shell and file tools to explore and implement.
reviewerAnalyze code for bugs, style, and improvements. Read files and run tests.
architectDesign systems and make technical decisions. Explore codebase structure.
testerWrite tests and find edge cases. Use shell tools to run tests.
(other)Perform the role of {name} effectively. Use available tools as needed.

Provider Resolution

When provider is specified:

  • "ollama" -- creates OllamaClient with the given model (default: "glm-5:cloud")
  • Any other value -- falls back to the default LLMClient supplier with a warning

Idle Shutdown Manager

IdleShutdownManager (com.tnsai.server.health) auto-shuts down the server after a configurable idle period when no active WebSocket connections exist (ADR-2: hybrid auto-daemon).

IdleShutdownManager manager = new IdleShutdownManager(
    connectionManager,           // WsConnectionManager instance
    Duration.ofMinutes(30),      // idle timeout
    () -> server.stop()          // shutdown action
);

manager.start();
// If no connections exist at start, schedules shutdown

// Called by WebSocket handlers
manager.onConnectionOpened();  // cancels pending shutdown
manager.onConnectionClosed();  // schedules shutdown if no connections remain

manager.stop();  // cleanup

Behavior:

  • On connection open: cancels any pending shutdown
  • On connection close: if no active connections remain, schedules shutdown after timeout
  • On timeout: re-checks for connections before shutting down (double-check safety)
  • Uses a daemon thread (tns-idle-shutdown) for the scheduler

RAG Pipeline

The RAG (Retrieval-Augmented Generation) system in com.tnsai.server.rag provides file indexing, code-aware chunking, and hybrid retrieval.

RagService

Orchestrates the full RAG pipeline for a single session. Thread-safe: indexing is serialized via a lock; reads (search) are concurrent.

RagService rag = new RagService();

// Index a directory
rag.indexDirectory(Path.of("/project/src"), progress ->
    System.out.printf("Indexing: %d/%d - %s%n",
        progress.indexedFiles(), progress.totalFiles(), progress.currentFile()));

// Search the knowledge base
List<SearchResult> results = rag.search("authentication logic", 5);

// Build a context-augmented prompt
String augmentedPrompt = rag.buildContextPrompt("How does auth work?", 3);
// Returns:
// [Relevant code context]
// --- file: src/main/java/Auth.java (lines 10-30) ---
// <chunk content>
//
// [User question]
// How does auth work?

Document management:

// Add a document manually
String docId = rag.addDocument("Some text content",
    Map.of("source", "manual", "tag", "notes"));

// Remove a document
boolean removed = rag.removeDocument(docId);

// List all managed documents
List<RagService.DocumentInfo> docs = rag.listDocuments();
// DocumentInfo(id, preview, contentLength, metadata)

// Get a specific document
Optional<Document> doc = rag.getDocument(docId);

// Get index status
IndexStatus status = rag.getStatus();
// IndexStatus(fileCount, chunkCount, lastIndexedAt, indexedPath)

// Clear everything
rag.clear();

FileIndexer

Walks a directory tree and indexes supported source files into a KnowledgeBase.

Features:

  • Respects .gitignore and .tnsignore patterns
  • Skips binary files and known build/dependency directories
  • SHA-256 content hashing for incremental re-indexing (unchanged files are skipped)
  • Progress reporting via callback

Supported file extensions: java, ts, tsx, js, jsx, py, md, json, yml, yaml, xml, html, css, sh, sql, go, rs, rb, kt, scala, c, cpp, h

Skipped directories: .git, node_modules, build, dist, target, .idea, .vscode, .gradle, __pycache__, vendor, .next, out, coverage, .svn, .hg

Maximum file size: 512 KB

FileIndexer indexer = new FileIndexer();

FileIndexer.IndexResult result = indexer.index(
    Path.of("/project"),
    knowledgeBase,
    bm25Stream,
    progress -> System.out.println(progress.currentFile())
);
// IndexResult(fileCount, chunkCount)

// Force full re-index on next run
indexer.clearHashes();

CodeChunker

Splits source code files into semantically meaningful chunks for RAG indexing. Each chunk is a Document with metadata: file, startLine, endLine, language.

Chunking strategies by language:

LanguageStrategy
Java, Kotlin, ScalaClass and method boundary detection via regex
TypeScript, JavaScriptFunction, class, and arrow-function boundary detection
MarkdownHeading boundary detection
Everything elseFixed-size line groups (max 100 lines)

Files under 100 lines are kept as a single chunk.

// Chunk a file
List<Document> chunks = CodeChunker.chunk(
    Path.of("src/Auth.java"),  // file path (metadata)
    fileContent,                // file content string
    "java"                      // language key
);

// Detect language from filename
String lang = CodeChunker.detectLanguage("Auth.java");    // "java"
String lang = CodeChunker.detectLanguage("app.tsx");       // "tsx"
String lang = CodeChunker.detectLanguage("README.md");     // "md"
String lang = CodeChunker.detectLanguage("unknown.xyz");   // "text"

Hybrid Retrieval

RagService uses a HybridRetriever that combines two retrieval streams:

  • BM25Stream (weight 0.6) -- keyword-based BM25 scoring
  • KnowledgeBaseStream (weight 0.4) -- vector similarity from the KnowledgeBase

Results are merged and ranked by weighted score.

Cross-References

On this page