Class SCOPBridge

java.lang.Object
com.tnsai.integration.scop.SCOPBridge

public class SCOPBridge extends Object
Bridge between SCOP framework and TnsAI LLM providers. Resolves LLM config from an external LLMConfigurationSource and the annotation hierarchy (Role -> Agent -> Playground), routing as a unit and tuning field by field, dispatches requests to provider-specific endpoints, and executes @Action methods.

This is a facade that delegates to focused helpers:

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 Details

    • getInstance

      public static SCOPBridge 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) and llmConfigurationSource(LLMConfigurationSource) belongs to the instance it is called on, so apply it to the instance that executes the actions: configuring the result of a separate getInstance() call leaves every other instance unchanged, and some of these settings are then missing silently rather than with an error.

    • getInstance

      public static SCOPBridge getInstance(long readTimeoutSeconds)
      Returns a new SCOPBridge with a custom read timeout. Like getInstance(), every call returns a new instance whose configuration is its own.
    • liabilitySink

      public SCOPBridge liabilitySink(com.tnsai.accountability.LiabilitySink 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 - if liabilitySink is null
      Since:
      0.15.0
    • authorityScope

      public SCOPBridge authorityScope(com.tnsai.accountability.AuthorityScope 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 - if authorityScope is null
      Since:
      0.15.0
    • knowledgeScope

      public SCOPBridge knowledgeScope(com.tnsai.rag.KnowledgeScope scope)
      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

      public String buildSystemPromptFromAnnotations(Object target)
      Builds a system prompt from TnsAI annotations on the target. Delegates to SystemPromptBuilder.
    • hasTnsAIActions

      public boolean hasTnsAIActions(Object target)
      Checks if a target object has TnsAI @Action annotations.
    • getActionSpec

      public Optional<com.tnsai.metadata.ActionMetadata> getActionSpec(Object target, String actionName)
      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 invocation carries (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 name
      params - action parameters; null becomes empty
      resolvedPaths - source name to its location for this call, or null for none
      invocation - 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 @Action method on the target object. Routes through Core's ActionExecutor pipeline.

      A principal, carried by the current InvocationContext, and authorityScope(AuthorityScope) are required before dispatch; pass the invocation through executeAction(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 name
      params - action parameters; null becomes 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 @Action and locates named knowledge sources for this dispatch only.

      resolvedPaths is copied onto the dispatch context under RetrievalSpi.RESOLVED_SOURCE_PATHS_KEY. Action knowledge keeps reading the annotations; the map only replaces path for names the Role already declared. null gives none.

      Parameters:
      target - action host (Role or plain Object)
      actionName - declared action method name
      params - action parameters; null becomes empty
      resolvedPaths - source name to its location for this call, or null for none
      Returns:
      success, or a configuration/execution error
      Since:
      0.16.1
    • requireLoaded

      public Map<String,Integer> requireLoaded(Object target, Collection<String> names)
      Parameters:
      target - action host (Role or plain Object)
      names - declared, enabled sources read with use SEARCH
      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 of target in the binding that executeAction(Object, String, Map, Map) with the same target and resolvedPaths binds, 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 with use SEARCH
      resolvedPaths - source name to its location, as given to executeAction, or null for 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 with use SEARCH
      com.tnsai.rag.RetrievalException - when a named source did not load
      IllegalStateException - when no retrieval runtime is on the classpath, the knowledge scope is closed, the target's class has taken a different roles section, or the owner's roles section 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 roles
      UnsupportedOperationException - when the retrieval runtime has no preflight
      Since:
      0.18.0
    • requireReady

      public Map<String,Integer> requireReady(Object target, com.tnsai.rag.KnowledgeScope scope)
      Parameters:
      target - action host (Role or plain Object)
      scope - the knowledge scope the host later sets with knowledgeScope(com.tnsai.rag.KnowledgeScope), or null for 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 configured WHEN_CONFIGURED source of target is ready: its file exists and loads at least one unit, in the binding a dispatch of target with the same scope and resolvedPaths reads. 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 accepts null, 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, or null for the process-wide one
      resolvedPaths - source name to its location, as given to executeAction, or null for 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 reason
      com.tnsai.rag.RetrievalException - unchanged, when it comes from outside a source's load and count: CONFIGURATION when an action of the target reads a WHEN_CONFIGURED source DIRECT, or the binding's own lease or build
      IllegalArgumentException - for a source the knowledge generation cannot hold or read
      IllegalStateException - when the knowledge scope is closed, the target's class has taken a different roles section, or the owner's roles section names a role the owner does not play yet
      UnsupportedOperationException - when the retrieval runtime has no readiness check
      Since:
      0.18.1
    • llmConfigurationSource

      public SCOPBridge llmConfigurationSource(LLMConfigurationSource 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's Agents section sit above it, so this is the place for settings shared by every agent, such as a run's LLM section; an agent type's own settings reach the bridge through the run file, as resolveLLMSpec(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 an endpoint, which decides where that value is sent — read the trust paragraph on LLMConfigurationSource before registering one.

      Parameters:
      llmConfigurationSource - the external source, e.g. LLMConfigurationSource.folder(Path) or LLMConfigurationSource.environment(); LLMConfigurationSource.noOp() to disable the layer
      Returns:
      this bridge
      Throws:
      NullPointerException - if llmConfigurationSource is null
      Since:
      0.16.4
    • resolveLLMSpec

      public Optional<LLMConfiguration> resolveLLMSpec(Object role, Object agent, Object playground)
      Resolves LLM configuration, deriving the external source's lookup key from agent — see resolveLLMSpec(Object, Object, Object, String).
      Throws:
      IllegalStateException - if a @RoleSpec role's class has taken a different roles section 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 file roles section > run file agent type section > role's own @Config > agent @Config roles section > 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 a model, 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 — temperature and maxTokens — 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 because hasModel() was false.

      A setting no layer declares keeps the default LLMSpec declares for it.

      A tier is read through LLMOverrides.fromAnnotation(LLMSpec), which treats a member as declared when it differs from LLMSpec'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 model without provider therefore gets the LLMSpec default provider, not a lower tier's — which is exactly what that annotation means on its own, since provider() defaults to OLLAMA.

      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 model without provider keeps the annotation's provider, so the two must agree.

      Above the external source sit the llm levels of the class configurations (Config): the agent's file, then the roles section bound for the role, then the role's own file, then the agent type's section of the run file a host passed to ClassConfig.useBaseDirectory(ClassConfig.RunFile) and the roles section 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 naming model alone 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 own getName() — which com.tnsai.agents.Agent declares — and falls back to the simple class name; pass it explicitly to override that derivation. A null or blank key skips the source.
      Throws:
      LLMConfigurationException - if the external source holds settings for the agent but they cannot be read
      IllegalStateException - if a @RoleSpec role's class has taken a different roles section 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 gives roles that name no role the agent plays, or that cannot be checked because the agent does not list its roles
      Since:
      0.16.4
    • resolveLLMClient

      public Optional<com.tnsai.llm.LLMClient> resolveLLMClient(Object target)
      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 @RoleSpec role, has taken a different roles section in this process, from another agent class or from a dispatch with no owner
    • sendToLLM

      public LLMResponse sendToLLM(ChatTurn turn)
      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 @State nor knowledge. With owners, every owner's @State is 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, as prepareConversationForDispatch(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 Agent chat turn sees (KnowledgeDeclarations.chatScope(List)): the enabled SEARCH and DIRECT entries of its @AgentSpec(knowledge = ...), or of its @RoleSpec(knowledge = ...) when its class carries @RoleSpec without @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 with maxTokens must 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 through ChatKnowledgeBinding.invalidate() and ChatKnowledgeBinding.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 @AgentSpec roles. 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 - when turn is null
      IllegalArgumentException - 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 array
      IllegalStateException - 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 rule
      com.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 without maxTokens or the model input window is unknown, or an owner's retrieval fails in a way its failure policy does not let open
      com.tnsai.cancellation.CancellationException - when the retrieval context's cancellation token is cancelled while an owner's knowledge is given or searched; a retrieval engine's java.util.concurrent.CancellationException also 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 State are read. Static persona and instruction content stays in place while any prior bridge-marked Current State section 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 history
      conversationOwners - 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 a Collection
      Returns:
      a request-local deep copy, never the input instance
      Throws:
      NullPointerException - if conversation is null
      IllegalArgumentException - if an entry of conversationOwners is a Collection, or annotated state must be added to a system message whose content is neither text nor a structured content array
      Since:
      0.16.0