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 explicit AgentPrincipal and AuthorityScope wiring through the fluent setters. 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), principal(AgentPrincipal), authorityScope(AuthorityScope), liabilitySink(LiabilitySink), resolvedSourcePaths(Map) 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.
    • getInstance

      public static SCOPBridge getInstance(com.tnsai.identity.AgentPrincipal principal, com.tnsai.accountability.LiabilitySink liabilitySink, com.tnsai.accountability.AuthorityScope authorityScope)
      Returns a new SCOPBridge whose action executor is already accountability-wired. Like getInstance(), every call returns a new instance whose configuration is its own.
      Parameters:
      principal - identity recorded on every dispatch
      liabilitySink - sink that receives per-dispatch records
      authorityScope - authority recorded on every dispatch
      Returns:
      wired bridge
      Throws:
      NullPointerException - if any argument is null
      Since:
      0.15.0
    • principal

      public SCOPBridge principal(com.tnsai.identity.AgentPrincipal principal)
      Parameters:
      principal - identity recorded on every dispatch
      Returns:
      this bridge
      Throws:
      NullPointerException - if principal is null
      Since:
      0.15.0
    • 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
    • resolvedSourcePaths

      public SCOPBridge resolvedSourcePaths(Map<String,Path> resolvedSourcePaths)
      Supplies run-time locations for @Knowledge names whose path is empty or a placeholder.

      Subsequent executeAction(Object, String, Map) calls put this map on the dispatch context so action knowledge (SEARCH, DIRECT and SEQUENTIAL) binds through the same locate-and-override rules as RoleRagBinding.forRole(Class, Map). A per-call map on executeAction(Object, String, Map, Map) wins for that dispatch only.

      Parameters:
      resolvedSourcePaths - source name to its location; copied
      Returns:
      this bridge
      Throws:
      NullPointerException - if resolvedSourcePaths or a value is null
      Since:
      0.16.1
    • 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.
      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.

      principal(AgentPrincipal) and authorityScope(AuthorityScope) are required before dispatch. 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 falls back to resolvedSourcePaths(Map).

      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 to use the fluent setter
      Returns:
      success, or a configuration/execution error
      Since:
      0.16.1
    • requireLoaded

      public Map<String,Integer> requireLoaded(Object target, Collection<String> names)
      requireLoaded(Object, Collection, Map) with no per-call locations, so the bridge-level ones from resolvedSourcePaths(Map) apply, as they do for executeAction(Object, String, Map).
      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 or, for null, the bridge-level ones, 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 sees neither the bridge-level locations nor 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 to use the fluent setter
      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)
      requireReady(Object, com.tnsai.rag.KnowledgeScope, Map) with no per-call locations, so the bridge-level ones from resolvedSourcePaths(Map) apply, as they do for executeAction(Object, String, Map).
      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 to use the fluent setter
      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(LLMConfiguration config, org.json.JSONArray conversation, org.json.JSONArray tools)
      Sends a message to the LLM and returns the response. Delegates to LLMDispatcher.
    • 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 be used immediately before both buffered and streaming LLM dispatches so action/tool-loop mutations are visible to the follow-up request without growing stored history.

      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
      Returns:
      a request-local deep copy, never the input instance
      Throws:
      NullPointerException - if conversation is null
      IllegalArgumentException - if annotated state must be added to a system message whose content is neither text nor a structured content array
      Since:
      0.16.0
    • sendToLLM

      public LLMResponse sendToLLM(LLMConfiguration config, org.json.JSONArray conversation, org.json.JSONArray tools, Object conversationOwner, com.tnsai.rag.ChatKnowledgeBinding binding, com.tnsai.rag.ChatRetrievalContext context)
      Sends a SCOP conversation through owner-scoped chat retrieval before dispatching it to the configured provider.

      The exact conversationOwner supplies the @AgentSpec(knowledge = ...) declarations; its enabled SEARCH entries are the ones the binding may search. The caller supplies the matching owner-scoped binding and request-local context; the bridge does not discover owners from global state or keep retrieval lifecycle state of its own. Run, episode, reset, and end transitions remain explicit through ChatKnowledgeBinding.invalidate() and ChatKnowledgeBinding.close().

      The owner's chat knowledge is the one an Agent chat turn sees (KnowledgeDeclarations.chatScope(List)): enabled SEARCH and DIRECT entries, SEQUENTIAL ignored, GRAPH left out. DIRECT entries are given whole through the retrieval runtime on every call, within their budget against the input window of config's model, and render around the SEARCH evidence in declaration order. The bridge keeps no state for them, so each call loads and measures them again.

      Knowledge is added only to a deep copy of the outbound conversation. A call without a retrieval context, owners without chat knowledge, empty results, and recoverable retrieval failures preserve the existing direct-dispatch behavior. A binding that belongs to another owner fails the call even without a retrieval context.

      Parameters:
      config - provider-neutral LLM configuration
      conversation - caller-owned conversation history
      tools - optional provider-neutral tool declarations
      conversationOwner - exact object whose chat annotations apply; chat-enabled bindings must be created with the same object through an exact-owner ChatKnowledgeBinding factory
      binding - exact-owner-scoped live retrieval binding, or null when the owner has no SEARCH chat knowledge or the optional retrieval runtime is unavailable
      context - immutable request-local scope and lifecycle context, or null when the optional retrieval runtime is unavailable
      Returns:
      provider response
      Throws:
      IllegalArgumentException - when the owner's knowledge and the supplied binding disagree
      IllegalStateException - when the owner's DIRECT knowledge has no retrieval runtime to give it or exceeds its budget, or its knowledge breaks a declaration rule
      Since:
      0.14.0