Class SCOPBridge
This is a facade that delegates to focused helpers:
SystemPromptBuilder- annotation-to-prompt conversionLLMDispatcher- provider-specific HTTP transport (fallback)BridgeLLMClient- LLMClient adapter over HTTP fallbackLLMConfiguration- resolved LLM config (provider, model, endpoint, keys)ActionExecutionResult- action execution result wrapperLLMResponse- LLM response wrapper
When Core's LLMClientProvider SPI is available on the classpath
(e.g., tnsai-llm module), this bridge uses real provider implementations
(OllamaClient, OpenAIClient, etc.) with full streaming and retry support.
Otherwise, it falls back to BridgeLLMClient + LLMDispatcher.
executeAction(Object, String, Map) requires an
AuthorityScope wired through the fluent setter and an
AgentPrincipal carried per call by the
InvocationContext given to
executeAction(Object, String, Map, Map, com.tnsai.security.InvocationContext):
a bridge serves several agents and conversations, so the principal is never
held on it. A LiabilitySink is optional and is never selected by default.
Missing identity/authority wiring fails before retrieval or the
action body so a SEQUENTIAL knowledge cursor cannot advance and then
still return an error.
-
Method Summary
Modifier and TypeMethodDescriptionauthorityScope(com.tnsai.accountability.AuthorityScope authorityScope) Records this authority scope on every dispatch.com.tnsai.security.AccessRequestRechecks current execution binding without hooks, retrieval, approval consumption or invocation.Builds a system prompt from TnsAI annotations on the target.voidconfigureExecution(com.tnsai.security.AccessSubject agent) Configure the existing action gate once at trusted host startup.voidconfigureExecution(com.tnsai.security.AccessSubject agent, Function<Object, String> targetSystem) Optional trusted lookup for existing AuthorityScope target-system bounds.Executes a TnsAI@Actionmethod on the target object.executeAction(Object target, String actionName, Map<String, Object> params, Map<String, Path> resolvedPaths) Executes a TnsAI@Actionand locates named knowledge sources for this dispatch only.executeAction(Object target, String actionName, Map<String, Object> params, Map<String, Path> resolvedPaths, com.tnsai.security.InvocationContext invocation) Trusted ingress overload.Optional<com.tnsai.metadata.ActionMetadata> getActionSpec(Object target, String actionName) Gets the ActionMetadata for a specific action on the target.static SCOPBridgeReturns a new SCOPBridge with the default read timeout (300s).static SCOPBridgegetInstance(long readTimeoutSeconds) Returns a new SCOPBridge with a custom read timeout.booleanhasTnsAIActions(Object target) Checks if a target object has TnsAI @Action annotations.knowledgeScope(com.tnsai.rag.KnowledgeScope scope) Uses an explicit knowledge-source lifetime for subsequent action dispatches.liabilitySink(com.tnsai.accountability.LiabilitySink liabilitySink) Publishes per-dispatch liability records to this sink.llmConfigurationSource(LLMConfigurationSource llmConfigurationSource) Registers the external layer consulted when LLM configuration resolves.org.json.JSONArrayprepareConversationForDispatch(org.json.JSONArray conversation, Object... conversationOwners) Creates an outbound request copy with a fresh dynamic state snapshot.requireLoaded(Object target, Collection<String> names) requireLoaded(Object, Collection, Map)with no locations, asexecuteAction(Object, String, Map)binds.requireLoaded(Object target, Collection<String> names, Map<String, Path> resolvedPaths) Loads the named SEARCH sources oftargetin the binding thatexecuteAction(Object, String, Map, Map)with the same target andresolvedPathsbinds, and returns the number of documents each holds.requireReady(Object target, com.tnsai.rag.KnowledgeScope scope) requireReady(Object, com.tnsai.rag.KnowledgeScope, Map)with no locations, asexecuteAction(Object, String, Map)binds.requireReady(Object target, com.tnsai.rag.KnowledgeScope scope, Map<String, Path> resolvedPaths) Checks, before a run, that every configuredWHEN_CONFIGUREDsource oftargetis ready: its file exists and loads at least one unit, in the binding a dispatch oftargetwith the same scope andresolvedPathsreads.Optional<com.tnsai.llm.LLMClient> resolveLLMClient(Object target) Resolves an LLMClient for the given target.resolveLLMSpec(Object role, Object agent, Object playground) Resolves LLM configuration, deriving the external source's lookup key fromagent— seeresolveLLMSpec(Object, Object, Object, String).resolveLLMSpec(Object role, Object agent, Object playground, String agentName) Resolves LLM configuration following the hierarchy: run filerolessection > run file agent type section > role's own@Config> agent@Configrolessection > agent@Config> external source > Role > Agent > Playground.Sends one chat turn to the configured provider.
-
Method Details
-
getInstance
Returns a new SCOPBridge with the default read timeout (300s).Every call returns a new instance: this is a factory, not an accessor for a shared bridge. Configuration such as
configureExecution(com.tnsai.security.AccessSubject),authorityScope(AuthorityScope),liabilitySink(LiabilitySink)andllmConfigurationSource(LLMConfigurationSource)belongs to the instance it is called on, so apply it to the instance that executes the actions: configuring the result of a separategetInstance()call leaves every other instance unchanged, and some of these settings are then missing silently rather than with an error. -
getInstance
Returns a new SCOPBridge with a custom read timeout. LikegetInstance(), every call returns a new instance whose configuration is its own. -
liabilitySink
Publishes per-dispatch liability records to this sink.There is no silent no-op default. Callers choose the sink explicitly, including test recording sinks.
- Parameters:
liabilitySink- sink that receives per-dispatch records- Returns:
- this bridge
- Throws:
NullPointerException- ifliabilitySinkisnull- Since:
- 0.15.0
-
authorityScope
Records this authority scope on every dispatch.Use
AuthorityScope.unrestricted(java.time.Duration)when the caller deliberately chose not to narrow — that is still an explicit decision, not a hidden default.- Parameters:
authorityScope- authority recorded on every dispatch- Returns:
- this bridge
- Throws:
NullPointerException- ifauthorityScopeisnull- Since:
- 0.15.0
-
knowledgeScope
Uses an explicit knowledge-source lifetime for subsequent action dispatches. The caller owns and closes the scope when its lifetime ends; active leased conversations keep resources until they finish. This bridge never closes it.- Parameters:
scope- knowledge-source lifetime, including one closing after this thread's turn has already acquired a lease- Returns:
- this bridge
- Since:
- 0.17.4
-
buildSystemPromptFromAnnotations
Builds a system prompt from TnsAI annotations on the target. Delegates toSystemPromptBuilder. -
hasTnsAIActions
Checks if a target object has TnsAI @Action annotations. -
getActionSpec
Gets the ActionMetadata for a specific action on the target. -
configureExecution
public void configureExecution(com.tnsai.security.AccessSubject agent) Configure the existing action gate once at trusted host startup. -
configureExecution
public void configureExecution(com.tnsai.security.AccessSubject agent, Function<Object, String> targetSystem) Optional trusted lookup for existing AuthorityScope target-system bounds. -
authorizeAction
public com.tnsai.security.AccessRequest authorizeAction(Object target, String actionName, Map<String, Object> params) Rechecks current execution binding without hooks, retrieval, approval consumption or invocation. -
executeAction
public ActionExecutionResult executeAction(Object target, String actionName, Map<String, Object> params, Map<String, Path> resolvedPaths, com.tnsai.security.InvocationContext invocation) Trusted ingress overload. Invocation identity is never read from params.The dispatch is attributed to the principal
invocationcarries (InvocationContext.principal()); without one it is refused before retrieval or the action body, as a call with no invocation is. Give each agent's or conversation's calls their own invocation: nothing of one call is kept on the bridge for the next.- Parameters:
target- action host (Role or plain Object)actionName- declared action method nameparams- action parameters;nullbecomes emptyresolvedPaths- source name to its location for this call, ornullfor noneinvocation- the trusted invocation, carrying the principal- Returns:
- success, or a configuration/execution error
- Since:
- 0.18.0
-
executeAction
public ActionExecutionResult executeAction(Object target, String actionName, Map<String, Object> params) Executes a TnsAI@Actionmethod on the target object. Routes through Core'sActionExecutorpipeline.A principal, carried by the current
InvocationContext, andauthorityScope(AuthorityScope)are required before dispatch; pass the invocation throughexecuteAction(Object, String, Map, Map, com.tnsai.security.InvocationContext).liabilitySink(LiabilitySink)is an optional recording destination. Missing wiring fails before retrieval or the action body so a SEQUENTIAL knowledge cursor cannot advance and then still return an error. Role and plain-Object hosts share this gate.- Parameters:
target- action host (Role or plain Object)actionName- declared action method nameparams- action parameters;nullbecomes empty- Returns:
- success, or a configuration/execution error that did not run retrieval or the action body when wiring is missing
-
executeAction
public ActionExecutionResult executeAction(Object target, String actionName, Map<String, Object> params, Map<String, Path> resolvedPaths) Executes a TnsAI@Actionand locates named knowledge sources for this dispatch only.resolvedPathsis copied onto the dispatch context underRetrievalSpi.RESOLVED_SOURCE_PATHS_KEY. Action knowledge keeps reading the annotations; the map only replacespathfor names the Role already declared.nullgives none.- Parameters:
target- action host (Role or plain Object)actionName- declared action method nameparams- action parameters;nullbecomes emptyresolvedPaths- source name to its location for this call, ornullfor none- Returns:
- success, or a configuration/execution error
- Since:
- 0.16.1
-
requireLoaded
requireLoaded(Object, Collection, Map)with no locations, asexecuteAction(Object, String, Map)binds.- Parameters:
target- action host (Role or plain Object)names- declared, enabled sources read withuseSEARCH- Returns:
- each name's document count, in sorted name order
- Since:
- 0.18.0
-
requireLoaded
public Map<String,Integer> requireLoaded(Object target, Collection<String> names, Map<String, Path> resolvedPaths) Loads the named SEARCH sources oftargetin the binding thatexecuteAction(Object, String, Map, Map)with the same target andresolvedPathsbinds, and returns the number of documents each holds. Nothing is retrieved and no action runs.The check goes through the code the dispatch runs: it binds the role to its owner's configuration, takes the per-call locations, and leases this bridge's
knowledge scope, so the binding checked is the one the dispatch leases. Use this rather than the retrieval runtime's own preflight for a target this bridge dispatches; that one does not see the owner's configuration.- Parameters:
target- action host (Role or plain Object)names- declared, enabled sources read withuseSEARCHresolvedPaths- source name to its location, as given toexecuteAction, ornullfor none- Returns:
- each name's document count, in sorted name order
- Throws:
IllegalArgumentException- when a name or a location names a source the target does not declare, or a name is not read withuseSEARCHcom.tnsai.rag.RetrievalException- when a named source did not loadIllegalStateException- when no retrieval runtime is on the classpath, the knowledge scope is closed, the target's class has taken a differentrolessection, or the owner'srolessection names a role the owner does not play yet; the role is bound as a dispatch binds it, so check it once the owner plays all its configured rolesUnsupportedOperationException- when the retrieval runtime has no preflight- Since:
- 0.18.0
-
requireReady
requireReady(Object, com.tnsai.rag.KnowledgeScope, Map)with no locations, asexecuteAction(Object, String, Map)binds.- Parameters:
target- action host (Role or plain Object)scope- the knowledge scope the host later sets withknowledgeScope(com.tnsai.rag.KnowledgeScope), ornullfor the process-wide one- Returns:
- each checked source's unit count, in sorted name order
- Since:
- 0.18.1
-
requireReady
public Map<String,Integer> requireReady(Object target, com.tnsai.rag.KnowledgeScope scope, Map<String, Path> resolvedPaths) Checks, before a run, that every configuredWHEN_CONFIGUREDsource oftargetis ready: its file exists and loads at least one unit, in the binding a dispatch oftargetwith the same scope andresolvedPathsreads. An unconfigured WHEN_CONFIGURED source is left out, and so is every MUST_LOAD source.The role is bound to its owner's configuration first, as a dispatch binds it. A target that then declares no knowledge, or a bridge with no retrieval runtime on the classpath, has nothing to check: the call returns an empty map without leasing the scope.
Pass the scope this bridge's dispatch will use, the one later given to
knowledgeScope(com.tnsai.rag.KnowledgeScope); another scope loads a generation the dispatch does not read. Unlike that setter, this method acceptsnull, meaning the process-wide cache, as a dispatch with no scope set uses.- Parameters:
target- action host (Role or plain Object)scope- the knowledge scope, ornullfor the process-wide oneresolvedPaths- source name to its location, as given toexecuteAction, ornullfor none- Returns:
- each checked source's unit count, in sorted name order
- Throws:
com.tnsai.exceptions.KnowledgeNotReadyException- when a checked source's file is missing, loads no unit, or fails to load or count, whatever the reasoncom.tnsai.rag.RetrievalException- unchanged, when it comes from outside a source's load and count:CONFIGURATIONwhen an action of the target reads a WHEN_CONFIGURED source DIRECT, or the binding's own lease or buildIllegalArgumentException- for a source the knowledge generation cannot hold or readIllegalStateException- when the knowledge scope is closed, the target's class has taken a differentrolessection, or the owner'srolessection names a role the owner does not play yetUnsupportedOperationException- when the retrieval runtime has no readiness check- Since:
- 0.18.1
-
llmConfigurationSource
Registers the external layer consulted when LLM configuration resolves.Whatever the source names wins over
@RoleSpec.llm()and@AgentSpec.llm(); whatever it leaves out keeps its annotation value. The class configuration levels and the run file'sAgentssection sit above it, so this is the place for settings shared by every agent, such as a run'sLLMsection; an agent type's own settings reach the bridge through the run file, asresolveLLMSpec(Object, Object, Object, String)describes. The source is read on every resolution and never cached here, so an edit is visible to the next resolution in the same process.A source is trusted infrastructure. It can name an
apiKeyEnv, which reads any environment variable or System property the process can see, and anendpoint, which decides where that value is sent — read the trust paragraph onLLMConfigurationSourcebefore registering one.- Parameters:
llmConfigurationSource- the external source, e.g.LLMConfigurationSource.folder(Path)orLLMConfigurationSource.environment();LLMConfigurationSource.noOp()to disable the layer- Returns:
- this bridge
- Throws:
NullPointerException- ifllmConfigurationSourceisnull- Since:
- 0.16.4
-
resolveLLMSpec
Resolves LLM configuration, deriving the external source's lookup key fromagent— seeresolveLLMSpec(Object, Object, Object, String).- Throws:
IllegalStateException- if a@RoleSpecrole's class has taken a differentrolessection in this process, from another agent class or from a dispatch with no owner
-
resolveLLMSpec
public Optional<LLMConfiguration> resolveLLMSpec(Object role, Object agent, Object playground, String agentName) Resolves LLM configuration following the hierarchy: run filerolessection > run file agent type section > role's own@Config> agent@Configrolessection > agent@Config> external source > Role > Agent > Playground.The annotation tiers are combined in two groups, because not every setting is safe to mix across authors:
- Routing —
provider,model,endpoint,apiKeyEnv— is taken whole from the highest-precedence tier that declares amodel, which is exactly the tier that used to win outright. A tier naming other routing fields without a model contributes nothing, as before, but now says so. These four only mean anything as a set: a model belongs to a provider, an endpoint speaks one provider's wire format, a key is issued by one provider. Letting them cross tier boundaries independently would send a role author's local model to an agent author's hosted endpoint under that host's key — a configuration neither author wrote. - Tuning —
temperatureandmaxTokens— merges field by field across every tier. These carry no routing meaning, so an agent that declares a temperature but no model now contributes that temperature to whichever tier supplies the routing. That merge is the change TAN-6201 asked for; before it, the whole tier was skipped becausehasModel()was false.
A setting no layer declares keeps the default
LLMSpecdeclares for it.A tier is read through
LLMOverrides.fromAnnotation(LLMSpec), which treats a member as declared when it differs fromLLMSpec's own default — necessarily, since@AgentSpec.llm()and@RoleSpec.llm()both default to@LLMSpec()and therefore carry a full set of member values whether or not their author wrote any of them.The one all-or-nothing rule that remains is the terminal one: if no layer at all supplies a model, the resolution yields
Optional.empty()rather than a configuration that names no model.A tier that declares
modelwithoutprovidertherefore gets theLLMSpecdefault provider, not a lower tier's — which is exactly what that annotation means on its own, sinceprovider()defaults toOLLAMA.The external source is the one layer that does overlay every field individually, including routing. That is deliberate and is what TAN-6201 requires: the tiers are different authors, whereas a source is one operator configuring a deployment, and requiring them to restate provider and temperature in every file is the duplication this layer exists to remove. A source naming
modelwithoutproviderkeeps the annotation's provider, so the two must agree.Above the external source sit the
llmlevels of the class configurations (Config): the agent's file, then therolessection bound for the role, then the role's own file, then the agent type's section of the run file a host passed toClassConfig.useBaseDirectory(ClassConfig.RunFile)and therolessection that gives the role. The bound sections are the ones the role's knowledge reads; with no agent they are empty, so only the role's own file applies, and an agent class that gives the role a section fails. Each overlays field by field, like the external source, so a file namingmodelalone keeps the provider resolved below it. The class configuration is read once per class and run.- Parameters:
agentName- lookup key for the external source. The no-key overload derives it from the agent's owngetName()— whichcom.tnsai.agents.Agentdeclares — and falls back to the simple class name; pass it explicitly to override that derivation. Anullor blank key skips the source.- Throws:
LLMConfigurationException- if the external source holds settings for the agent but they cannot be readIllegalStateException- if a@RoleSpecrole's class has taken a differentrolessection in this process, from another agent class or from a dispatch with no owner; if a class configuration file or the run file section breaks its rules (ClassConfig.of(java.lang.Class<?>),ClassConfig.useBaseDirectory(ClassConfig.RunFile)), such as a simple-name key two classes share; or if the run file section givesrolesthat name no role the agent plays, or that cannot be checked because the agent does not list its roles- Since:
- 0.16.4
- Routing —
-
resolveLLMClient
Resolves an LLMClient for the given target. Prefers Core's LLMClientProvider SPI when available; falls back to BridgeLLMClient.- Throws:
IllegalStateException- if the target's class, a@RoleSpecrole, has taken a differentrolessection in this process, from another agent class or from a dispatch with no owner
-
sendToLLM
Sends one chat turn to the configured provider.The turn's owners decide what the request carries. With no owners the conversation is dispatched with neither
@Statenor knowledge. With owners, every owner's@Stateis refreshed on each request and, with a retrieval context, the owners' chat knowledge is given and searched; without one, no owner's knowledge is read. In both cases the dispatched conversation is a deep copy of the caller's, with any prior bridge-marked state removed, asprepareConversationForDispatch(org.json.JSONArray, java.lang.Object...)gives it: a conversation that already holds a bridge-marked state section, sent without owners, goes out without that section, and a system message that held only it goes out without that message.The bridge prepares every turn itself, so a buffered send needs no
prepareConversationForDispatch(org.json.JSONArray, java.lang.Object...)call first: give the turn the stored conversation and list the owners whose state it carries. A conversation already prepared for those owners is prepared again the same way; one sent without them loses their state.A turn is one request: its retrieval context carries that request's deadline, cancellation and turn message, so each send builds its own turn.
An owner's chat knowledge is the one an
Agentchat turn sees (KnowledgeDeclarations.chatScope(List)): the enabled SEARCH and DIRECT entries of its@AgentSpec(knowledge = ...), or of its@RoleSpec(knowledge = ...)when its class carries@RoleSpecwithout@AgentSpec; SEQUENTIAL is ignored and GRAPH left out. The turn sees their union: the owners in list order, each owner's blocks in its declaration order, and each owner's SEARCH evidence where its own first SEARCH entry was declared, or after its blocks when it declares none, fitted to its own binding's context window, all behind one boundary. A DIRECT entry withmaxTokensmust fit that value; the DIRECT entries without it, of every owner together, share one default budget of 25% of the model input window, so owners that each fit on their own can fail together. Each call loads and measures every owner's DIRECT entries again; the bridge keeps no retrieval lifecycle state of its own, and run, episode, reset and end transitions stay explicit throughChatKnowledgeBinding.invalidate()andChatKnowledgeBinding.close().An owner's recoverable retrieval failure, empty result or oversized evidence fails open for that owner only; any other retrieval failure fails the call, and a failure while rendering the turn dispatches the whole turn ungrounded. Owners are searched one after another under the one context, so the turn's retrieval time is their sum and a later owner may find the context's deadline already spent.
The caller decides which owners take part; a role owner is not checked against an agent's
@AgentSpecroles. Declaration rules, such as unique knowledge names and at most one MEMORY entry, apply to each owner's own declarations, not to the union, so a source or name that two owners declare is given once for each of them.Bindings stay exact-owner, as
ChatTurn.Builder.build()checks. An owner has at most one binding checked against its declarations and any number of undeclared bindings, each checked for its owner alone and refused, with a retrieval context, when its source name is the name of knowledge the owner declares. No two bindings of an owner may search the same source name, since their evidence would carry the same label. An owner is searched with each of its bindings, its declared binding first and its undeclared ones in the order given; an owner whose SEARCH knowledge has no binding is not searched.A request section is added only to the outbound copy, never to the caller's conversation, so it is not carried into the next request. It ends the last system message, before the owners' refreshed state, as text or as a text part of structured content; a conversation without a system message is opened with one holding it. Its dynamic state markers are escaped, so it cannot open or close the state section. On a grounded request, the untrusted-context policy is appended to the last system message only when that message does not already hold its exact text, from the section, the state or anything else; a section that quotes the policy verbatim therefore keeps the policy paragraph from being appended, and can frame or contradict it.
- Parameters:
turn- the turn to send- Returns:
- provider response
- Throws:
NullPointerException- whenturnisnullIllegalArgumentException- when an owner holds two bindings checked against its declarations or two bindings searching the same source, an owner's knowledge and its binding disagree, or the request section or state must be added to a system message whose content is neither text nor a structured content arrayIllegalStateException- when, with a retrieval context, an owner's DIRECT knowledge has no retrieval runtime to give it or an owner's knowledge breaks a declaration rulecom.tnsai.rag.RetrievalException- when an owner's DIRECT knowledge cannot be given whole or exceeds its budget, the owners' DIRECT knowledge together exceeds the shared default budget or cannot be checked against it because the retrieval runtime reports no DIRECT usage row for an entry withoutmaxTokensor the model input window is unknown, or an owner's retrieval fails in a way its failure policy does not let opencom.tnsai.cancellation.CancellationException- when the retrieval context's cancellation token is cancelled while an owner's knowledge is given or searched; a retrieval engine'sjava.util.concurrent.CancellationExceptionalso passes through- Since:
- 0.18.6
-
prepareConversationForDispatch
public org.json.JSONArray prepareConversationForDispatch(org.json.JSONArray conversation, Object... conversationOwners) Creates an outbound request copy with a fresh dynamic state snapshot.The caller-owned conversation is never mutated. Only fields explicitly annotated with
Stateare read. Static persona and instruction content stays in place while any prior bridge-markedCurrent Statesection is replaced by one versioned request-local section. Calls with no annotated owners return a deep copy after removing any prior bridge-marked state.When multiple owners contribute state, owners are rendered in caller encounter order and each owner's fields are sorted lexically by their effective
state name. Repeated references to the same owner object are rendered once. This transport-neutral preparation step must run immediately before every dispatch the caller makes itself, such as a streaming one, so action/tool-loop mutations are visible to the follow-up request without growing stored history.sendToLLM(ChatTurn)runs it for the turn's owners, so a conversation sent through it is given as stored, with those owners listed; a prepared copy sent without them loses their state.Diagnostics log only owner and field counts; state values are never written to logs by this bridge.
Prompts persisted from bridge versions that predate versioned dynamic markers must be rebuilt once. This method deliberately does not infer section boundaries from untrusted Markdown headings.
- Parameters:
conversation- caller-owned conversation historyconversationOwners- exact owner or role objects whose annotated state contributes to this request; null entries and annotation-free objects are ignored, and each owner is passed itself, not inside aCollection- Returns:
- a request-local deep copy, never the input instance
- Throws:
NullPointerException- ifconversationisnullIllegalArgumentException- if an entry ofconversationOwnersis aCollection, or annotated state must be added to a system message whose content is neither text nor a structured content array- Since:
- 0.16.0
-