# SCOP bridge

The Integration module connects TnsAI with SCOP, a separate Java multi-agent development and experimentation framework, via reflection-based discovery. No compile-time dependency on SCOP is required, because detection happens at runtime. The bridge also provides an HTTP fallback transport for 11 LLM providers when Core's native SPI implementations are unavailable.

Learn more about SCOP at [scop-framework.netlify.app](https://scop-framework.netlify.app/).

## Quick start

The `SCOPBridge` is a singleton facade that handles everything: building system prompts from annotations, executing actions, and resolving LLM clients. Here is a minimal example showing the three main operations.

```java
SCOPBridge bridge = SCOPBridge.getInstance();
AuthorityScope scope = AuthorityScope.unrestricted(Duration.ofHours(1));
bridge.authorityScope(scope);

// Build a system prompt from TnsAI annotations
String systemPrompt = bridge.buildSystemPromptFromAnnotations(roleObject);

// Execute a TnsAI action; the invocation carries the principal it runs as
InvocationContext invocation = InvocationContext.autonomous(
        executingAgent, principal.id(), scope, principal);
ActionExecutionResult result = bridge.executeAction(
        roleObject, "searchPapers", params, null, invocation);
if (result.isSuccess()) {
    System.out.println(result.getResult());
}

// Resolve an LLM client (prefers Core SPI, falls back to HTTP)
Optional<LLMClient> client = bridge.resolveLLMClient(roleObject);
```

Every dispatch needs a principal and an authority scope. The principal
comes from the `InvocationContext` the call runs under. `authorityScope(..)`
is set on the bridge, and `liabilitySink(..)` is optional. A dispatch without
an invocation principal is refused before retrieval or the action body runs.
Give each agent's or conversation's calls their own invocation: the bridge
keeps nothing of one call for the next. The three-argument
`executeAction(target, name, params)` works only inside an invocation that
already carries a principal, for example within `invocation.call(..)`. See
[Restrict who can trigger an action](../security/execution-policy.md).

## SCOPBridge

The `SCOPBridge` is the main entry point for all SCOP integration. It acts as a facade that delegates to specialized helpers for prompt building, LLM communication, and action execution.

| Component | Responsibility |
|-----------|---------------|
| `SystemPromptBuilder` | Annotation-to-markdown prompt conversion |
| `LLMDispatcher` | Provider-specific HTTP transport (fallback) |
| `BridgeLLMClient` | `LLMClient` adapter over HTTP fallback |
| `LLMConfiguration` | Resolved LLM config (provider, model, endpoint, keys) |
| `ActionExecutionResult` | Action execution result wrapper |

### Factory methods

The bridge is created as a singleton. You can optionally configure the HTTP read timeout for LLM calls.

```java
// Default read timeout (300 seconds)
SCOPBridge bridge = SCOPBridge.getInstance();

// Custom read timeout
SCOPBridge bridge = SCOPBridge.getInstance(120);  // 120 seconds
```

On construction, the bridge attempts to discover Core's `LLMClientProvider` SPI via `ServiceLoader`. If found, all LLM calls route through real provider implementations (with streaming, retry, etc.). Otherwise, the HTTP fallback path is used.

### System prompt building

The bridge can automatically generate a structured system prompt by reading TnsAI annotations from your role class. This means you define the agent's identity, capabilities, and behavior through annotations, and the bridge converts them into a prompt the LLM can understand.

```java
String prompt = bridge.buildSystemPromptFromAnnotations(myRole);
```

Extracted annotations:

- `@RoleSpec`: Role identity, description, goals
- `@Communication`: Communication style guidance
- `@State`: Current state fields for context
- `@ActionSpec`: Available actions with descriptions and parameters

### Action execution

The bridge routes action calls through the TnsAI `ActionExecutor` pipeline, which dispatches to the appropriate executor based on the action type. The result includes the return value, pre/post narration text, and error details if something went wrong.

```java
Map<String, Object> params = Map.of("query", "sales trends", "limit", 10);
ActionExecutionResult result = bridge.executeAction(role, "analyzeData", params, null, invocation);

if (result.isSuccess()) {
    Object data = result.getResult();
    String pre = result.getPreNarration();    // Before-action narration
    String post = result.getPostNarration();  // After-action narration
    String display = result.getNarration();   // Smart: postNarration on success, errorMessage on failure
} else {
    String error = result.getErrorMessage();
    Exception cause = result.getException();
}
```

`executeAction` on a **non-`Role`** target runs the action's knowledge
once in `ActionExecutor.executeInternalForObject` via `applyRetrieval`,
then writes `_rag_context`. The target declares its sources with
`@RoleSpec(knowledge = ...)` and the action names them with
`@ActionSpec(knowledge = ...)`, as on a Role. See
[RAG](../rag/index.md#declarative-annotations). `SCOPBridge`
does not search again. The optional `RetrievalSpi` matches the Role path, but
Role security, `@Contract`, and `@InvariantCheck` do **not**.

Narration fallback (LOCAL only, not a SCOP `Action` return):

- non-blank method return wins, even when `_rag_context` is present
- blank or empty return + populated `_rag_context` →
  `"Action {name} returned: "` + retrieved text
- Conversation already surfaces `getNarration()`

Config errors from `RetrievalSpi.enforce` surface as
`ActionExecutionException(VALIDATION)`. Silent skip is not only a
missing `tnsai-rag` on the **consumer** classpath: empty
`RetrievalSpi.find()`, a null caller context, a blank or unpickable
query parameter, or `onFailure=CONTINUE` all leave the action running
without retrieved text. `tnsai-rag` stays test-scoped on
`tnsai-integration`, so the consumer must depend on it.

#### Per-call knowledge locations

The four-argument `executeAction(target, actionName, params, resolvedPaths)`
locates declared sources for one dispatch. An entry replaces only the
`path` of a source the target already declares. Every other field stays
with the annotation. `null` gives no locations. The paths are host inputs,
never model or tool action parameters.

```java
var result = bridge.executeAction(role, "recall", Map.of("question", "identity"),
        Map.of("facts", factsFile), invocation);
```

The trusted-ingress overload
`executeAction(target, actionName, params, resolvedPaths, invocation)` takes
the same map plus an `InvocationContext`. A `null` invocation, or one without
a principal, returns an authorization error result and runs nothing. The
four-argument form runs under the invocation already current on the thread. The bridge adds no source
declarations of its own: a source the class does not declare cannot be
introduced per call.

To refuse a run whose `SEARCH` knowledge did not load, call
`bridge.requireLoaded(target, names, resolvedPaths)` with the map the
dispatch will get before the first dispatch. It loads the binding that
`executeAction` with the same target and map leases, returns each source's
document count, and runs no action. The dispatch that follows does not load
that binding again.

This is **not** the `sendToLLM(ChatTurn)` chat path below.

### ActionExecutionResult

The `ActionExecutionResult` wraps the outcome of an action execution, providing a consistent API for both success and failure cases.

```java
// Factory methods
ActionExecutionResult.success(resultObject, preNarration, postNarration);
ActionExecutionResult.error(errorMessage, exception);

// Accessors
result.isSuccess();
result.getResult();           // The action return value
result.getPreNarration();     // Empty string if null
result.getPostNarration();    // Empty string if null
result.getNarration();        // Smart display text
result.getOutcome();          // What the action returned, or why it failed, without narration
result.getErrorMessage();
result.getException();
```

## Owner-aware chat grounding

`SCOPBridge.sendToLLM(ChatTurn)` is the one way to send a chat turn. A
`ChatTurn` holds the configuration, the conversation and everything that
shapes the request on its way out. Its owners decide what the request
carries:

- With no owners, the conversation is dispatched with no `@State` and no
  knowledge.
- With owners, every owner's `@State` is refreshed on each request.
- With owners and a retrieval context, the owners' chat knowledge is also
  given and searched. Without a context, no owner's knowledge is read.

Every turn sends a deep copy of the conversation with any prior
bridge-marked state removed. Give the turn the **stored** conversation and
list the owners whose state it carries. A conversation that
`prepareConversationForDispatch` returned holds its owners' state only
until it is sent: in a turn without those owners, that state is removed.
Keep `prepareConversationForDispatch` for provider calls the application
makes itself, such as streaming ones.

Action knowledge on `executeAction` is a **different door**
(above). Do not use a chat turn to stand in for it.

The generic chat-binding contract lives in [RAG](../rag/index.md)
and [the declarative RAG tutorial](../tutorials/grounded-action.md).

```java
@AgentSpec(knowledge = @Knowledge(
    name = "simulation-logs",
    type = Knowledge.Type.MEMORY))
final class ConversationOwner {
}

ConversationOwner owner = new ConversationOwner();
ChatKnowledgeBinding binding = ChatKnowledgeBinding.live(
    owner,
    "conversation-42",
    "simulation-logs",
    5,
    request -> simulationLog.retrieve(request),
    simulationLog::close
);

ChatRetrievalContext context = ChatRetrievalContext.builder()
    .scope("tenant-a", "session-42")
    .runId("run-9")
    .agentName("narrator")
    .simulationState("RUNNING")
    .currentEpisode("episode-4")
    .sourceRevision("event-offset-120")
    .build();

LLMResponse response = bridge.sendToLLM(
    ChatTurn.builder(config, storedConversation)
        .tools(tools)
        .owners(owner)
        .bindings(binding)
        .context(context)
        .build()
);
```

`simulationLog`, `bridge`, `config`, `storedConversation`, and `tools` are
application-owned. The binding's `topK` must equal the entry's: the chat default of `5` here,
since the entry sets none.

A turn is one request: its retrieval context carries that request's
deadline, cancellation and turn message, so build a new turn for each
send. The bridge reads the caller's conversation and tools when the turn
is sent and never mutates them.

### Building a turn

`ChatTurn.builder(config, conversation)` refuses a `null` configuration or
conversation. Each setter replaces what an earlier call to it set.

| Setter | Use when |
|---|---|
| `tools(tools)` | The provider should see tool declarations. `null` means none. |
| `owners(owner, ...)` or `owners(list)` | The conversation carries those owners' `@State`, or they ground the turn. List every owner whose state the stored conversation carries. |
| `bindings(binding, ...)` or `bindings(list)` | An owner's `SEARCH` knowledge should be searched. Leave it out for none. |
| `context(context)` | The owners' chat knowledge should be read for this request. Needs at least one owner. |
| `requestSection(section)` | A section rendered for this request alone, such as an agent's [recent activity](#agent-activity-record). `null` or blank adds nothing. |

`build()` checks the turn's shape, so a malformed turn fails where it is
built rather than when it is sent. It refuses a `null` owner or binding,
an owner that is a `Collection`, an owner listed twice by identity, a
binding of no listed owner, and a context with no owner.

[Migrate to 0.18](../upgrade/0.18.md#the-old-sendtollm-overloads-are-removed) maps each removed send overload onto the builder.

### Exact owner and `isOwnedBy`

Create the binding with the object-aware `ChatKnowledgeBinding.create` or
`ChatKnowledgeBinding.live` factory that takes the owner instance. The
binding holds that object in a `WeakReference`. `isOwnedBy` authorizes
with Java reference identity. Display names, `equals`, and `ownerId()`
are not authorization inputs. `ownerId()` is copied into retrieval
metadata only.

An owner's chat knowledge is the enabled `SEARCH` and `DIRECT` entries of
its `@AgentSpec(knowledge = ...)`, or of its `@RoleSpec(knowledge = ...)`
when its class carries `@RoleSpec` without `@AgentSpec`. It is read only
in a turn with a retrieval context. A legacy binding created without the
owner object cannot be passed for an owner. Each owner may hold:

- at most one binding checked against its declarations: its sources must
  be enabled `SEARCH` entries of that knowledge, with the `topK` those
  entries declare or the chat default of `5`.
- any number of `ChatKnowledgeBinding.undeclared` bindings over the
  application's own sources, checked for their exact owner only.

No two bindings of one owner may search the same source name, and an
undeclared binding may not use the name of knowledge its owner declares.
A binding of another owner is refused when the turn is built. The other
rules, and source or `topK` mismatches, raise `IllegalArgumentException`
when the turn is sent. An owner whose `SEARCH` knowledge has no binding is
not searched.

`DIRECT` entries of every owner are given whole on each grounded turn,
within their budget against the input window of `config`'s model, and
need a retrieval runtime such as `tnsai-rag`. Entries without `maxTokens`
share one default budget across all owners, so owners that each fit on
their own can fail together. The turn renders the owners in list order,
each owner's blocks in its declaration order.

### Request-local fencing and fail-open

Build one `ChatRetrievalContext` per request so tenant, session, run,
agent, simulation state, episode, source revision, cancellation, and
deadline stay request-local. Evidence is written only onto a deep copy of
the outbound conversation, inside the existing `<tnsai-memory>` fence plus
the untrusted-context disclosure. The caller's `JSONArray` and persisted
SCOP history stay unchanged.

A turn without a context reads no knowledge. A grounded turn whose latest
user message is missing or blank is dispatched ungrounded. Otherwise an
owner's recoverable retrieval failure, empty result or oversized evidence
fails open for that owner only, and the other owners keep their evidence. A
failure while rendering the turn dispatches the whole turn ungrounded.
Owners are searched one after another under the one context, so a later
owner may find the context's deadline already spent.

Cancellation, `DIRECT` budget failures and non-recoverable configuration
errors still surface.
Call `binding.invalidate()` on episode, run, or reset transitions that
change the live corpus, and `close()` at conversation or run end.
Closure is idempotent.

## Agent activity record

`AgentActivity` and `ActivityBlock` in `com.tnsai.integration.activity`
give an agent a record of what it recently did, for its LLM to read on the
next request. The `PathRedactor` they use is
`com.tnsai.redaction.PathRedactor` in `tnsai-core`, a
[`Masker`](../security/redaction.md#mask-paths-and-credentials). The record is
held in memory by the agent that owns it.

```java
PathRedactor redactor = new PathRedactor(Map.of(
    runFolder, "<run>",
    Path.of(System.getProperty("user.home")), "~"
));
AgentActivity<String> activity = new AgentActivity<>(40, 8_000, 400, redactor);

activity.received(tick, "carpenter", "request", incomingText, false);
ActionExecutionResult result = bridge.executeAction(role, "bend", params, null, invocation);
activity.acted(tick, "bend", !result.isSuccess(), result.getOutcome());
activity.noted(tick, "The plank is too dry to bend.");

String section = ActivityBlock.render(activity.read(), peer -> peer);
LLMResponse response = bridge.sendToLLM(
    ChatTurn.builder(config, storedConversation)
        .tools(tools)
        .owners(agent)
        .bindings(bindings)
        .context(context)
        .requestSection(section)
        .build()
);
```

`runFolder`, `tick`, `incomingText`, `role`, `params`, `agent`,
`bindings`, and the other inputs are application-owned. The type
parameter is how the caller identifies a peer. Here it is a plain name.

- **`AgentActivity`** is thread-safe and records four kinds of entry:
  `sent`, `received`, `acted` and `noted`. Three caps bound it: the
  number of entries, the characters over all entries, and the characters
  of one entry's type and text together, which may not exceed the whole.
  The oldest entries are dropped first, and the newest is always kept. Every
  text and type passes through the redactor first, and line breaks
  become spaces. `read()` returns an immutable snapshot, oldest first.
- **`ActivityBlock.render`** writes the entries under the heading
  `## Your recent activity`, with a line saying they are data, not
  instructions. Each entry is one line in the second person, such as
  `- [tick 3] Your action bend failed: IllegalStateException: the plank snapped`.
  The function you pass names each peer. A text that was cut short ends
  with ` [cut]`, and an empty record renders as the empty string.
- **`PathRedactor`** replaces the folders you give it with names, in the
  form given, in absolute normalized form, and in real form when the
  folder exists. A folder is matched only as a whole path, and a root is
  never replaced. It hides those folders only and is not a filter for
  other secrets.
- **`ActionExecutionResult.getOutcome()`** is the text to record for an
  action: its result's own `toString` on success, or `Action completed.`
  when there is nothing to show, and on failure the reason without the
  `Action <name> failed: ` prefix. Narration, retrieved context and the
  owner's `@State` are never used.
- **`ChatTurn.requestSection`** adds the section to
  the outbound copy only, at the end of the last system message and
  before the owners' refreshed `@State`. The caller's conversation never
  holds it, so the next request does not repeat it. A `null` or blank
  section adds nothing.

## LLM configuration

The `LLMConfiguration` class holds everything needed to connect to an LLM provider: the provider name, model, temperature, max tokens, endpoint URL, and which environment variable holds the API key. The bridge resolves this configuration automatically from your annotations.

### Resolution hierarchy

Four layers contribute, lowest precedence first: playground, agent (`@AgentSpec(llm = …)`), role (`@RoleSpec(llm = …)`), and a registered `LLMConfigurationSource`.

They do not all combine the same way, and a single "the higher layer wins" sentence describes neither of them correctly:

- **Routing** (`provider`, `model`, `endpoint`, `apiKeyEnv`) is taken **as a set** from the highest-precedence annotation tier that declares a `model`. No other tier contributes any part of it. A tier that names a routing field but no model contributes nothing and logs a warning naming what it dropped.
- **Tuning** (`temperature`, `maxTokens`) merges **field by field**, higher precedence winning per field.
- The external source is the only layer that overlays every field individually, routing included.

```java
Optional<LLMClient> client = bridge.resolveLLMClient(roleObject);
```

See [External LLM Configuration](../llm/configuration.md) for the source layer, the trust boundary it carries, and why routing is grouped rather than merged.

### Typed host configuration (`AgentConfiguration`)

A trusted host can parse the `LLM` and `Agents` sections of its
configuration into an immutable snapshot instead of passing them around as
generic parameters:

```json
{
  "LLM": { "model": "llama3.2", "temperature": 0.7 },
  "Agents": {
    "QuestionerAgent": { "temperature": 0.3 },
    "AnswererAgent": { "model": "llama3.1" }
  }
}
```

```java
AgentConfiguration config = AgentConfiguration.parse(sections);
config.validate(registeredTypes);  // complete set of agent classes in the run
AgentConfiguration.AgentSettings settings = config.resolve(agent.getClass(), registeredTypes);
```

- `parse(JSONObject)` accepts only these two sections. Other categories such
  as `Run` stay with the host. `LLMOverrides.fromJson` decodes a flat `LLM`
  object on its own, and `AgentConfiguration.AgentSettings.fromJson` also
  decodes the same flat fields for one agent entry. Unknown fields, wrong types and explicit `null`
  are configuration errors. Numbers must be JSON numbers, and an explicit `0`
  is kept as a value.
- `Agents` keys name **Java types**, not instance names. A fully qualified
  binary name matches only a registered class, and a short name must be unique in
  the run. Unknown types, ambiguous short names, and two keys for the same
  class fail. No class is loaded from configuration.
- Call `validate(...)` with the complete type registry before any LLM call,
  source ingestion or action. `shared()` is available during setup.
- `resolve(...)` composes shared `LLM` fields with the type entry. The host's
  existing layers then apply field by field: explicit run/web > exact-instance
  individual file > exact-instance central entry > type entry > shared `LLM` >
  annotations and defaults. Endpoint and credential trust are unchanged.
- Parsing a path grants no read authority and does no file or network I/O.
  The source runtime still checks roots and generated paths.

Knowledge is not configured here. An agent entry with `knowledgeSources`
fails with `knowledgeSources moved to the class's knowledge declaration`.
Declare the knowledge on the class, and locate it per call with the
`resolvedPaths` map above.

The full contract is in the
[`tnsai-integration` README](https://github.com/TnsAI-Framework/TnsAI/blob/main/tnsai-integration/README.md#typed-shared-llm-and-agent-type-configuration).

### Supported providers

The bridge supports 11 LLM providers out of the box. Most use the OpenAI-compatible API format, while Anthropic and Gemini have custom formatting.

| Provider | API Format | Default Endpoint | API Key Env Var |
|----------|-----------|-----------------|-----------------|
| `OLLAMA` | OpenAI-compatible | `http://localhost:11434/v1/chat/completions` | `OLLAMA_API_KEY` |
| `OPENAI` | OpenAI-compatible | `https://api.openai.com/v1/chat/completions` | `OPENAI_API_KEY` |
| `ANTHROPIC` | Native (tool format conversion) | `https://api.anthropic.com/v1/messages` | `ANTHROPIC_API_KEY` |
| `GEMINI` | Native | `https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent` | `GEMINI_API_KEY` |
| `AZURE_OPENAI` | OpenAI-compatible, `api-key` header | User-configured | `AZURE_OPENAI_API_KEY` |
| `BEDROCK` | AWS-specific; only through the `tnsai-llm` provider, the HTTP fallback returns an error | User-configured | `AWS_ACCESS_KEY_ID` |
| `GROQ` | OpenAI-compatible | `https://api.groq.com/openai/v1/chat/completions` | `GROQ_API_KEY` |
| `TOGETHER` | OpenAI-compatible | `https://api.together.xyz/v1/chat/completions` | `TOGETHER_API_KEY` |
| `MISTRAL` | OpenAI-compatible | `https://api.mistral.ai/v1/chat/completions` | `MISTRAL_API_KEY` |
| `DEEPSEEK` | OpenAI-compatible | `https://api.deepseek.com/chat/completions` | `DEEPSEEK_API_KEY` |
| `CUSTOM` | OpenAI-compatible | User-configured | `CUSTOM_LLM_API_KEY` |

OpenAI-compatible providers (`OLLAMA`, `OPENAI`, `GROQ`, `TOGETHER`, `MISTRAL`, `DEEPSEEK`, `AZURE_OPENAI`, `CUSTOM`) share the same request/response format. Anthropic and Gemini have provider-specific formatting.

`LLMSpec.Provider` declares one constant this table does not cover: `HUGGINGFACE` has no default endpoint here, and a provider absent from the table falls back to the Ollama default carrying whatever `apiKeyEnv` came with it. An externally supplied `HUGGINGFACE` is therefore refused. Annotations are not checked, so `@LLMSpec(provider = Provider.HUGGINGFACE)` still misroutes.

### Endpoint resolution

The bridge resolves the API endpoint using a three-step fallback chain, so you can override endpoints per-role, per-environment, or rely on provider defaults.

1. Custom endpoint from `@LLMSpec.endpoint()` (if non-empty)
2. Base URL from environment variable (e.g., `OPENAI_BASE_URL`) + provider chat path
3. Default endpoint from the provider table above

```java
LLMConfiguration config = new LLMConfiguration(
    "OPENAI", "gpt-4o", 0.7f, 4096, "", "OPENAI_API_KEY");

String apiUrl = config.resolveApiUrl();  // Checks custom -> env var -> default
String apiKey = config.resolveApiKey();  // Checks custom env -> default env
```

## LLMDispatcher (HTTP fallback)

The `LLMDispatcher` is the fallback LLM transport used when TnsAI Core's native provider SPI is not on the classpath. Do not raise its logger to `DEBUG`. See [Log levels](#log-levels). It communicates with all 11 LLM providers over HTTP using OkHttp, building provider-specific request bodies and parsing responses. It builds provider-specific request bodies, sends them via `OkHttpClient`, and parses responses.

```java
LLMResponse response = dispatcher.sendToLLM(config, conversationArray, toolsArray);

// Response types
response.getContent();       // Text content
response.getToolCalls();     // Tool call requests (if any)
response.getErrorMessage();  // Local diagnostic (if failed) -- not display-safe
response.getSafeErrorMessage();  // Fixed text for users (if failed)
```

The dispatcher handles:

- OpenAI-compatible request/response format for 8 providers
- Anthropic-specific message format and tool schema conversion
- Gemini-specific content format
- Azure OpenAI endpoint path construction
- API key header injection (`Authorization: Bearer` for most, `x-api-key` for Anthropic)

## BridgeLLMClient (adapter pattern)

The `BridgeLLMClient` wraps the HTTP-based `LLMDispatcher` behind the standard `LLMClient` interface. This adapter pattern means the rest of TnsAI does not need to know whether it is talking to a native SPI provider or the HTTP fallback, because the API is identical.

```java
// Created internally by SCOPBridge when resolving LLM clients
BridgeLLMClient client = new BridgeLLMClient(config, dispatcher);

// Implements LLMClient
client.getModel();        // From LLMConfiguration
client.getTemperature();  // From LLMConfiguration
client.getMaxTokens();    // Optional<Integer> from LLMConfiguration

// Standard chat interface
ChatResponse response = client.chat(
    message,
    Optional.of(systemPrompt),
    Optional.of(history),
    Optional.of(tools)
);
```

The adapter translates between Core's `ChatResponse` format and the `LLMResponse` format used by the dispatcher.

## Design patterns

The integration module uses several well-known design patterns to keep the code modular and testable. This table summarizes them for contributors and architects.

| Pattern | Usage |
|---------|-------|
| **Facade** | `SCOPBridge` delegates to `SystemPromptBuilder`, `LLMDispatcher`, `BridgeLLMClient` |
| **Adapter** | `BridgeLLMClient` adapts `LLMDispatcher` to the `LLMClient` interface |
| **SPI Discovery** | Prefers Core's `LLMClientProvider` when available on classpath |
| **Reflection** | Detects SCOP classes at runtime without compile-time dependency |
| **Configuration Hierarchy** | Role -> Agent -> Playground annotation resolution |

---

The sections below cover internal mechanics of the SCOP Bridge: the annotation-to-prompt conversion pipeline, SCOP Action detection and execution, LLM client resolution internals, and provider-specific request formatting.

## SystemPromptBuilder

`com.tnsai.integration.scop.SystemPromptBuilder` converts TnsAI annotations into structured Markdown system prompts. The bridge calls `SystemPromptBuilder.buildFromAnnotations(target)` to generate a prompt the LLM can understand.

### Annotation extraction order

The builder reads annotations from the target class in this order:

1. **`@RoleSpec`**: Role identity, description, BDI goals (`@Goal`)
2. **`@Communication`** (from `@RoleSpec.communication()` or class-level): Tone, formality, verbosity, persona, languages, response format, max length
3. **`@State`** fields: Current runtime state values (read via reflection)
4. **`@ActionSpec`** methods: Available actions with descriptions and types
5. **`@Coordination`**: Negotiation preferences (protocol, max rounds, timeout, concession strategy)

### Generated prompt structure

```markdown
# Role: MyAssistant

## Description
A helpful assistant that answers questions about products.

## Responsibilities
- **ProductSearch**: Find products matching user queries
  Actions: searchProducts, filterResults
  Invariants: always return at most 10 results

## Communication Style
- **Tone**: Friendly
- **Formality**: Casual
- **Verbosity**: Concise
- **Allowed Languages**: en
- **CRITICAL LANGUAGE RULE**: You MUST ALWAYS respond in **EN** only...

## Current State
- **mood**: happy (Current emotional state)
- **conversationCount**: 5 (Number of exchanges)

## Available Actions
- **searchProducts**: Search the product catalog [LLM]
- **placeOrder**: Place an order for a product [EXTERNAL_API]

## Negotiation Preferences
- **Protocol**: CONTRACT_NET
- **Max Rounds**: 5
- **Timeout**: 30
- **Reservation Value**: 0.5
- **Concession Strategy**: LINEAR
```

### Communication style rules

The builder generates strict language enforcement rules:

- **Single language**: Generates a `CRITICAL LANGUAGE RULE` requiring the LLM to always respond in that language, even if the user writes in another language.
- **Multiple languages**: Generates a softer rule: respond in the user's language if it is in the allowed list, otherwise default to the first language.
- **Default detection**: If `@Communication` has all default values (NEUTRAL tone, NEUTRAL formality, NORMAL verbosity, single "en" language), the class-level `@Communication` annotation is checked as a fallback.

```java
// Single language -> strict enforcement
@Communication(languages = {"tr"})
// -> "CRITICAL LANGUAGE RULE: You MUST ALWAYS respond in TR only."

// Multiple languages -> flexible matching
@Communication(languages = {"en", "tr", "de"})
// -> "Respond in the same language the user writes in..."
```

### Enum formatting

Enum names are formatted for display: `VERY_FORMAL` becomes `Very Formal`. This conversion uses `Locale.ROOT` for the `toLowerCase` call.

```java
// Internal: SystemPromptBuilder.formatEnum("VERY_FORMAL") -> "Very Formal"
```

## Tool exposure for LLM actions

When a role action is declared `@ActionSpec(type = LLM)`, the LLM call uses the agent's `ToolMethodDispatcher`, built once at `AgentBuilder.build()` time from the registered POJO toolkits and dynamic tools. There is no per-action tool list any more, so every `LLM` action sees the agent's complete tool registry.

Configure tools at the agent level:

```java
import com.tnsai.enums.BuiltInTool;

Agent agent = AgentBuilder.create()
    .llm(llmClient)
    .role(myRole)
    .builtInTools(
        BuiltInTool.CSV_TOOLS,        // csv_summary, csv_columns, csv_filter, …
        BuiltInTool.WEB_SEARCH_TOOLS  // brave_search, duckduckgo, wikipedia, …
    )
    .toolPojos(new MyDomainTools())
    .build();
```

For per-action LLM overrides (system prompt, temperature) without touching the agent's global config, set them on `@ActionSpec` directly:

```java
@ActionSpec(
    type = ActionType.LLM,
    description = "Summarise a CSV file's contents",
    llmSystemPrompt = "You are a precise data analyst. Cite columns by name.",
    llmTemperature = 0.2f
)
public String analyzeCsv(String path) {
    return "Summarise the CSV at: " + path;
}
```

See [Tools / Catalog](../tools/catalog.md) for the full per-toolkit method list and [Tool Integration](../tools/register.md) for the registration paths.

## SCOP action detection and execution

The bridge detects and executes SCOP framework actions through reflection, with no compile-time dependency on SCOP.

### Detection

`SCOPBridge.isSCOPAction(Object)` checks whether a returned object is a SCOP Action by looking for `ai.scop.core.Action` in the class hierarchy:

1. Attempts `Class.forName("ai.scop.core.Action")` and uses `isInstance()`
2. If class loading fails, walks the superclass chain looking for a match by name

### Execution flow

When an `@ActionSpec` method returns a SCOP Action object, the bridge executes it:

1. Calls `action.execute()` via reflection
2. Attempts to call `action.getPostNarration()` for a result description
3. If no narration is available, falls back to `buildStateResponse()` which reads all `@State`-annotated fields to build a state summary
4. If neither provides text, returns a generic success message

```
executeAction(target, "negotiate", params)
  -> ActionExecutor.execute(actionSpec, target, params, context)
  -> result is SCOP Action?
     YES -> executeSCOPAction(result, "negotiate", target)
            -> result.execute()
            -> result.getPostNarration() || buildStateResponse(target) || generic
     NO  -> "Action negotiate returned: " + result
```

### LLM client injection

For actions with `ActionType.LLM`, the bridge resolves an LLM client and injects it into the execution context before the action runs:

```java
if (actionSpec.getType() == ActionType.LLM) {
    Optional<LLMClient> llmClient = resolveLLMClient(target);
    if (llmClient.isPresent()) {
        context.put("llm", llmClient.get());
    }
}
Object result = actionExecutor.execute(actionSpec, target, params, context);
```

### State response building

`buildStateResponse(Object target, String actionName)` reads all `@State`-annotated fields from the target class via reflection:

```java
// For a target with @State fields: mood="happy", energy=80
// Returns: "After negotiate: mood: happy, energy: 80"
```

## LLM client resolution internals

`SCOPBridge.resolveLLMClient(Object target)` resolves an LLM client through a multi-step process:

### Owner chain resolution

The target's owner agent and playground are resolved via reflection:

```
target.getOwner() -> agent
agent.getPlayground() -> playground
```

These are used for the annotation hierarchy lookup.

### SPI vs fallback decision

```
resolveLLMClient(target)
  -> resolveLLMSpec(target, agent, playground)  // Get LLMConfiguration
  -> clientProvider != null?
     YES -> clientProvider.create(provider, model, temperature, maxTokens, null)
            -> Real Core LLMClient (OllamaClient, OpenAIClient, etc.)
            -> Falls back to HTTP if SPI creation fails
     NO  -> new BridgeLLMClient(config, dispatcher)
            -> HTTP-based fallback
```

### SPI provider discovery

On construction, `SCOPBridge` calls `LLMClientProvider.discover()` to find Core's SPI implementation. If `tnsai-llm` module is on the classpath, this returns a provider that creates real client instances with streaming and retry support.

```java
// Logged at startup:
// "[SCOPBridge] Using Core LLMClientProvider SPI for LLM calls"
// or:
// "[SCOPBridge] Core LLMClientProvider SPI not found; using HTTP fallback"
```

The first line is logged at `DEBUG`, the fallback line at `INFO`.

### Log levels

Routine diagnostics in `SCOPBridge`, `LLMDispatcher` and
`LLMClientFactory` are logged at `DEBUG`. They include
action execution, LLM resolution, client creation and request/response
summaries. `WARN` and `ERROR` records keep their levels, and so do the
`INFO` records for the missing SPI with HTTP fallback, source-override
roots and external source routing.

To see the bridge's own diagnostics, raise the
`com.tnsai.integration.scop.SCOPBridge` logger to `DEBUG`. Client
creation logs under `com.tnsai.llm.LLMClientFactory`, which is also safe
to raise: it logs the API-key environment variable name, not the key.
The request/response summaries come from `LLMDispatcher` and cannot be
raised safely.

Do not set `DEBUG` on `LLMDispatcher`, on root or on any parent package
logger (such as `com.tnsai` or `com.tnsai.integration.scop`):
at `DEBUG`, `LLMDispatcher` logs the last four characters of the API
key.

## Provider-specific request formatting

The `LLMDispatcher` handles the differences between LLM provider APIs. Understanding these details is useful when debugging integration issues.

### OpenAI-compatible providers

Eight providers share the same request format: OLLAMA, OPENAI, GROQ, TOGETHER, MISTRAL, DEEPSEEK, AZURE_OPENAI, CUSTOM.

Request body structure:

```json
{
    "model": "gpt-4o",
    "messages": [...],
    "temperature": 0.7,
    "max_tokens": 4096,
    "tools": [...]
}
```

Headers: `Authorization: Bearer <key>` (except Azure, which uses `api-key: <key>`).

### Anthropic format

Anthropic uses a different message format. The dispatcher:

1. Extracts `system` role messages and puts them in a top-level `"system"` field
2. Converts OpenAI tool format to Anthropic format (`"parameters"` becomes `"input_schema"`)
3. Sets `anthropic-version: 2024-10-22` header and `x-api-key` header
4. Parses response content blocks (`"text"` and `"tool_use"` types)

Tool format conversion:

```
OpenAI format:                     Anthropic format:
{ "type": "function",              { "name": "search",
  "function": {                      "description": "Search docs",
    "name": "search",                "input_schema": { ... }
    "description": "Search docs",  }
    "parameters": { ... }
  }
}
```

### Gemini format

Gemini uses a distinct content structure:

1. Maps `"assistant"` role to `"model"`
2. Wraps text in `{ "parts": [{ "text": "..." }] }` format
3. Extracts system prompt into `"systemInstruction"` field
4. Converts tools to `"functionDeclarations"` format
5. Passes API key as a query parameter (`?key=...`)
6. Uses `"generationConfig"` for temperature and `"maxOutputTokens"`

### Response parsing

Each provider has a dedicated parser:

| Provider | Method | Response Structure |
|----------|--------|--------------------|
| OpenAI-compatible | `parseOpenAIResponse()` | `choices[0].message.content` or `.tool_calls` |
| Anthropic | `parseAnthropicResponse()` | `content[]` blocks of type `text` or `tool_use` |
| Gemini | `parseGeminiResponse()` | `candidates[0].content.parts[]` with `text` or `functionCall` |

### LLMResponse types

The `LLMResponse` wrapper normalizes responses across all providers:

```java
public class LLMResponse {
    public enum Type { TEXT, TOOL_CALLS, ERROR }

    // Factory methods
    static LLMResponse text(String content, JSONObject rawMessage);
    static LLMResponse toolCalls(JSONArray toolCalls, JSONObject rawMessage);
    static LLMResponse error(String errorMessage);
    static LLMResponse error(String diagnosticMessage, Exception failure);

    // Query methods
    boolean isSuccess();         // type != ERROR
    boolean hasToolCalls();      // type == TOOL_CALLS
    boolean hasText();           // type == TEXT
    String getErrorMessage();    // Local diagnostic; not display-safe
    Exception getException();    // Original local failure, or null
    String getSafeErrorMessage();  // Display-safe error text
    String getDisplayContent();  // Text content, or "[ERROR] " + diagnostic for errors
}
```

#### Showing an error to a user

Use `getSafeErrorMessage()` wherever an error reaches a user, a public DTO or
an ordinary log line. It returns fixed text chosen only from the category of
the retained `LLMException` (for example "The LLM provider is temporarily
overloaded."). With no typed failure retained it returns a generic message,
and it returns `null` for responses that are not errors. It never echoes the
diagnostic or provider text.

`getErrorMessage()` is a local diagnostic. It can contain the provider's
error message or URLs, so it is **not** display-safe. The raw response body
stays in the local `ProviderDetails`.

`getDisplayContent()` is unchanged: for an error it still returns
`"[ERROR] "` followed by that same diagnostic, so it is **not** display-safe
either. Use it for text responses only, or for local debugging.

`getException()` returns the original local failure. Jackson and org.json
bean serialization skip it. Never serialize the returned exception or its
provider details into a public response.

## BridgeLLMClient internals

The `BridgeLLMClient` adapts the HTTP-based `LLMDispatcher` to Core's `LLMClient` interface. It translates between the two APIs:

### chat() method

1. Builds a `JSONArray` conversation from `systemPrompt`, `history`, and `message`
2. Converts `tools` list to `JSONArray`
3. Calls `dispatcher.sendToLLM(config, conversation, toolsArray)`
4. Translates `LLMResponse.toolCalls` into `ChatResponse.ToolCall` records
5. Returns `ChatResponse.withToolCalls(...)` or `ChatResponse.textOnly(...)`

### streamChat() limitation

Streaming is not supported through the HTTP fallback path. The `streamChat()` method delegates to `chat()` and wraps the result in a single-element `Stream<String>`:

```java
@Override
public Stream<String> streamChat(...) {
    ChatResponse response = chat(message, systemPrompt, history, tools);
    return Stream.of(response.getContent());
}
```

For real streaming support, ensure the `tnsai-llm` module is on the classpath so the SPI path is used instead.

## Endpoint resolution details

`LLMConfiguration.resolveApiUrl()` follows a three-step fallback:

| Priority | Source | Example |
|----------|--------|---------|
| 1 | `@LLMSpec.endpoint()` (annotation) | `https://my-custom-llm.example.com/v1/chat` |
| 2 | Environment variable + path | `OPENAI_BASE_URL=https://proxy.example.com` + `/v1/chat/completions` |
| 3 | Provider default | `https://api.openai.com/v1/chat/completions` |

Base URL environment variables per provider:

| Provider | Env Var |
|----------|---------|
| `OLLAMA` | `OLLAMA_BASE_URL` |
| `OPENAI` | `OPENAI_BASE_URL` |
| `ANTHROPIC` | `ANTHROPIC_BASE_URL` |
| `GEMINI` | `GEMINI_BASE_URL` |
| `AZURE_OPENAI` | `AZURE_OPENAI_BASE_URL` |
| `GROQ` | `GROQ_BASE_URL` |
| `TOGETHER` | `TOGETHER_BASE_URL` |
| `MISTRAL` | `MISTRAL_BASE_URL` |
| `DEEPSEEK` | `DEEPSEEK_BASE_URL` |
| `CUSTOM` | `CUSTOM_LLM_BASE_URL` |

API key resolution follows the same pattern: annotation `apiKeyEnv` first, then provider default env var. Ollama does not require an API key.

## Error handling

### LLM errors

The dispatcher catches and wraps errors at multiple levels:

- **IOException**: Connection errors, timeouts -> diagnostic `"Connection error: ..."`
- **JSONException**: Malformed responses -> diagnostic `"Invalid response format: ..."`
- **API errors**: A provider `error` object in the body -> diagnostic `"<Provider> Error: <provider message>"`
- **HTTP errors without an `error` body** -> diagnostic `"<Provider> API error: HTTP <status>"`
- **Empty 2xx response** -> diagnostic `"Empty response from <Provider>"`
- **Missing API key**: Detected before the request -> diagnostic `"API key not configured for ..."`

A non-2xx response is always an error, even when its body
parses as a reply. The dispatcher classifies it by status before parsing
the body and returns it with a typed `LLMException` (see `getException()`),
so `getSafeErrorMessage()` can name the category:

| Status | `LLMException.ErrorType` |
|--------|--------------------------|
| 401, 403 | `AUTHENTICATION_FAILED` |
| 429 | `MODEL_OVERLOADED` |
| other 4xx | `INVALID_REQUEST` |
| 5xx | `SERVER_ERROR` |
| any other non-2xx | `UNKNOWN` |

An empty, non-JSON or unreadable error body does not erase a known status.
The raw body is kept only in the local `ProviderDetails`. When the body
carries a JSON `error` field, the provider's error message goes into the
diagnostic, so `getErrorMessage()` and the `LLMException` message carry it, but
`getSafeErrorMessage()` never does. The fallback does
not infer a missing model from HTTP 404 or parse provider text to diagnose
context length. Transport and response-parsing failures, an empty 2xx body
and a 2xx body carrying an `error` object are typed `UNKNOWN`. Failures
detected before the request (such as a missing API key) carry no typed
exception, so `getSafeErrorMessage()` returns its generic message for them.

`BridgeLLMClient.chat()` rethrows a typed `LLMException` as is, with its
original instance, metadata and cause. It also rethrows cancellation,
retrieval, security and LLM configuration failures found in the cause
chain. Any other failure becomes an `UNKNOWN` `LLMException` that keeps the
original failure as its cause.

Before 0.17.5 every bridge failure was typed `UNKNOWN`. See [Migrate to 0.18](../upgrade/0.18.md#bridge-retries-follow-the-error-type-0175) for how that changes a `RetryPolicy`.

### Action errors

Action execution failures are wrapped in `ActionExecutionResult.error()`:

```java
// Not found
ActionExecutionResult.error("Action not found: searchPapers", null);

// Execution failure
ActionExecutionResult.error("Action analyzeData failed: NullPointerException", exception);

// Null target
ActionExecutionResult.error("Target object is null", null);
```

## Configuration reference

### LLMConfiguration

Full constructor:

```java
new LLMConfiguration(
    String provider,      // "OPENAI", "ANTHROPIC", "GEMINI", etc.
    String model,         // "gpt-4o", "claude-sonnet-4-20250514", etc.
    float temperature,    // 0.0 - 2.0
    int maxTokens,        // 0 = provider default
    String endpoint,      // Custom endpoint URL (empty = use default)
    String apiKeyEnv      // Custom env var name (empty = use default)
)
```

Query methods:

| Method | Returns |
|--------|---------|
| `hasModel()` | Whether model is set (non-null, non-blank) |
| `hasEndpoint()` | Whether a custom endpoint is configured |
| `isOpenAICompatible()` | Whether the provider uses OpenAI-compatible format |
| `requiresApiKey()` | `true` for all providers except OLLAMA |
| `resolveApiUrl()` | Full API URL (annotation -> env var -> default) |
| `resolveApiKey()` | API key (annotation env -> default env -> null) |
| `getBaseUrlEnvVar()` | Environment variable name for base URL |
| `getApiKeyEnvVar()` | Environment variable name for API key |
