diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bde25cf..2291405d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Refactored the engine's internal runtime composition into an immutable component snapshot; zero public API change and no execution-semantic changes, except the intentional fail-fast rejection of invalid partial approval composition at the engine component boundary. + - **Explicit runtime lifecycle ownership (PR #226).** `Tramai` and `SovereignTramai` are now `AutoCloseable` and own exactly one lazily-created runtime (one engine) shared by every `create()`/`runtime()` call — previously every `create()` leaked an unreachable engine. Closing is idempotent and concurrency-safe; after close, `create()`/`runtime()` and old proxies fail fast with a fixed `IllegalStateException` before any provider work. `TramaiEngine.close()` cancels once and awaits engine-hierarchy termination (self-close safe), and terminates in-flight suspend invocations; the caller continuation is always resumed exactly once. Spring closes the shared runtime via `destroyMethod = "close"`, so multiple `@AiService` beans share one owned engine. TramAI closes only resources it creates; externally supplied providers/stores/clients/observers remain caller-owned. API surface addition is additive: `Tramai`/`SovereignTramai` gain `close()`; all constructor descriptors remain byte-identical to 0.5.0 (note: adding the `AutoCloseable` supertype is source-compatible but affects compiled negative-`instanceof` checks). Epic 1.3 Runtime Lifecycle Ownership is complete. - **Safe persistence failure boundaries (PR #225).** Persistence stores expose fixed, cause-free failure text; raw paths, SQL, and payloads flow only to `PersistenceFailureDiagnosticObserver`; worker observers receive safe failures; existing exception and store ABI is preserved by the binary fixture. Epic 1.2 Safe Error Boundaries is complete. diff --git a/config/quality/maintainability-deviations.yml b/config/quality/maintainability-deviations.yml index 754127be..52f1b56e 100644 --- a/config/quality/maintainability-deviations.yml +++ b/config/quality/maintainability-deviations.yml @@ -156,6 +156,16 @@ deviations: targetPhase: "0.6.1" owner: "GionaGranchelli" + - id: MQ-0017 + metric: globalMutableState + scope: ":tramai-engine" + baseline: 18 + allowed: 22 + reason: "Epic 2.1 EngineComponents snapshot: internal data-class fields typed as ProviderRegistry/ToolRegistry/ModelRegistry are immutable final snapshot references, not mutable registries. Scanner counts them as exposed registry state; they are the frozen configuration boundary (EngineComponentFactory is the sole construction path). Findings resolve when the scanner learns to distinguish snapshot references." + acceptedAt: "2026-08-12" + targetPhase: "0.6.1" + owner: "GionaGranchelli" + - id: MQ-0015 metric: nondeterminismSources scope: ":tramai-orchestration" diff --git a/docs/ROADMAP-0.6.0.md b/docs/ROADMAP-0.6.0.md index e03a6f65..2355c914 100644 --- a/docs/ROADMAP-0.6.0.md +++ b/docs/ROADMAP-0.6.0.md @@ -421,6 +421,8 @@ This phase is intentionally completed before large decomposition work. ## Epic 2.1: Introduce immutable runtime component groups +**Status: ✅ Complete — PR #228** + **Goal:** Replace constructor and builder explosion with cohesive, inspectable runtime configuration. ### Proposed component model @@ -441,18 +443,18 @@ The exact API may differ, but each group must have one responsibility and explic ### Tasks -1. Introduce immutable component groups without changing public builder APIs initially. -2. Move all all-or-none composition validation into component constructors or factories. -3. Distinguish required components, optional capabilities, and no-op implementations. -4. Replace nullable dependency clusters with explicit capability types where possible. -5. Document thread-safety and lifecycle ownership for every component group. -6. Ensure component snapshots are immutable after runtime construction. +1. ✅ Introduce immutable component groups without changing public builder APIs initially. +2. ✅ Move all all-or-none composition validation into component constructors or factories. +3. ✅ Distinguish required components, optional capabilities, and no-op implementations. +4. ✅ Replace nullable dependency clusters with explicit capability types where possible. +5. ✅ Document thread-safety and lifecycle ownership for every component group. +6. ✅ Ensure component snapshots are immutable after runtime construction. ### Acceptance criteria -- `TramaiEngine` and its main execution coordinators receive cohesive component groups rather than dozens of unrelated dependencies. -- Invalid partial approval, policy, persistence, or evidence configurations fail during build. -- Runtime code does not discover configuration dynamically. +- ✅ `TramaiEngine` and its main execution coordinators receive cohesive component groups rather than dozens of unrelated dependencies. +- ✅ Invalid partial approval, policy, persistence, or evidence configurations fail during build. +- ✅ Runtime code does not discover configuration dynamically. --- diff --git a/docs/modules/tramai-engine.md b/docs/modules/tramai-engine.md index 306e1612..0c2f3349 100644 --- a/docs/modules/tramai-engine.md +++ b/docs/modules/tramai-engine.md @@ -70,6 +70,7 @@ implementation("dev.tramai:tramai-engine") ### Quick usage The most common pattern: create a `TramaiEngine` with a single provider, then call `engine.create()` to obtain a proxy. +Public construction remains unchanged. Internally, each construction call validates and freezes an immutable component snapshot before runtime use. ```kotlin import dev.tramai.engine.TramaiEngine diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt index f685d8d6..4b637cd7 100644 --- a/tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt @@ -120,6 +120,9 @@ import kotlin.reflect.KClass import kotlin.reflect.KFunction import kotlin.reflect.KParameter import kotlin.reflect.jvm.kotlinFunction +import dev.tramai.engine.components.ApprovalCapability +import dev.tramai.engine.components.EngineComponentFactory +import dev.tramai.engine.components.EngineComponents private const val MAX_SAFE_TOOL_NAME_LENGTH = 128 private const val UNREGISTERED_TOOL_NAME = "unregistered_tool" @@ -127,59 +130,88 @@ private const val UNREGISTERED_TOOL_NAME = "unregistered_tool" /** * Runtime engine that turns annotated service interfaces into AI-backed proxies. */ -class TramaiEngine( - private val providerRegistry: ProviderRegistry, - private val structuredOutputHandler: StructuredOutputHandler? = null, - private val toolRegistry: ToolRegistry = ToolRegistry(), - private val operationObserver: OperationObserver = NoOpOperationObserver, - private val operationInterceptor: OperationInterceptor = NoOpOperationInterceptor, - private val responseCache: OperationResponseCache = NoOpOperationResponseCache, - private val modelRegistry: ModelRegistry = NoOpModelRegistry, - private val modelRegistrySettings: ModelRegistrySettings = ModelRegistrySettings(), - private val circuitBreakerSettings: CircuitBreakerSettings = CircuitBreakerSettings(), - private val retryPolicySettings: RetryPolicySettings = RetryPolicySettings(), - private val tokenBudgetSettings: TokenBudgetSettings = TokenBudgetSettings(), - private val promptSanitizer: PromptSanitizer? = null, - private val chatMemory: ChatMemory? = null, - private val conversationIdProvider: ConversationIdProvider = UuidConversationIdProvider(), - private val job: Job = SupervisorJob(), - private val scope: CoroutineScope = CoroutineScope(job + Dispatchers.Default), - private val policyEngine: dev.tramai.core.policy.PolicyEngine? = null, - private val dlpInterceptor: DlpInterceptor = NoOpDlpInterceptor, - private val dlpRedactionAuditEmitter: DlpRedactionAuditEmitter = NoOpDlpRedactionAuditEmitter, - private val toolResultFilteringSettings: ToolResultFilteringSettings = ToolResultFilteringSettings(), - private val engineEventObserver: EngineEventObserver = NoOpEngineEventObserver, - private val toolFailureDiagnosticObserver: ToolFailureDiagnosticObserver = NoOpToolFailureDiagnosticObserver, - private val policyDecisionAuditEmitter: PolicyDecisionAuditEmitter = NoOpPolicyDecisionAuditEmitter, - // Approval suspension dependencies - private val suspendedInvocationStore: SuspendedInvocationStore = InMemorySuspendedInvocationStore(), - private val approvalContinuationStore: ApprovalContinuationStore? = null, - private val toolArgumentsDigester: ToolArgumentsDigester? = null, - private val approvalGateCoordinator: ApprovalGateCoordinator? = null, - private val approvalLifecycleAuditEmitter: ApprovalLifecycleAuditEmitter = NoOpApprovalLifecycleAuditEmitter, - private val clock: Clock = Clock.systemUTC(), +class TramaiEngine private constructor( + private val components: EngineComponents, ) : AutoCloseable { - /** - * Structured-output failure diagnostic observer. Additive configuration - * (not a primary-constructor parameter — preserves the published JVM - * constructor descriptors); set via the observer-aware secondary - * constructor. Defaults to no-op. - */ - private var structuredOutputFailureDiagnosticObserver: StructuredOutputFailureDiagnosticObserver = - NoOpStructuredOutputFailureDiagnosticObserver + private val providerRegistry = components.providers.providerRegistry + private val structuredOutputHandler = components.execution.structuredOutputHandler + private val toolRegistry = components.tools.toolRegistry + private val operationObserver = components.observation.operationObserver + private val operationInterceptor = components.observation.operationInterceptor + private val responseCache = components.persistence.responseCache + private val modelRegistry = components.security.modelRegistry + private val modelRegistrySettings = components.security.modelRegistrySettings + private val circuitBreakerSettings = components.execution.circuitBreakerSettings + private val retryPolicySettings = components.execution.retryPolicySettings + private val tokenBudgetSettings = components.execution.tokenBudgetSettings + private val promptSanitizer = components.security.promptSanitizer + private val chatMemory = components.persistence.chatMemory + private val conversationIdProvider = components.persistence.conversationIdProvider + private val dlpInterceptor = components.security.dlpInterceptor + private val dlpRedactionAuditEmitter = components.security.dlpRedactionAuditEmitter + private val toolResultFilteringSettings = components.tools.toolResultFilteringSettings + private val engineEventObserver = components.observation.engineEventObserver + private val toolFailureDiagnosticObserver = components.observation.toolFailureDiagnosticObserver + private val policyDecisionAuditEmitter = components.security.policyDecisionAuditEmitter + private val suspendedInvocationStore = components.approvals.suspendedInvocationStore + private val approvalContinuationStore = (components.approvals.capability as? ApprovalCapability.Enabled)?.continuationStore + private val toolArgumentsDigester = (components.approvals.capability as? ApprovalCapability.Enabled)?.argumentsDigester + private val approvalGateCoordinator = (components.approvals.capability as? ApprovalCapability.Enabled)?.gateCoordinator + private val approvalLifecycleAuditEmitter = components.approvals.approvalLifecycleAuditEmitter + private val clock = components.execution.clock + private val structuredOutputFailureDiagnosticObserver = components.observation.structuredOutputFailureDiagnosticObserver + + constructor( + providerRegistry: ProviderRegistry, + structuredOutputHandler: StructuredOutputHandler? = null, + toolRegistry: ToolRegistry = ToolRegistry(), + operationObserver: OperationObserver = NoOpOperationObserver, + operationInterceptor: OperationInterceptor = NoOpOperationInterceptor, + responseCache: OperationResponseCache = NoOpOperationResponseCache, + modelRegistry: ModelRegistry = NoOpModelRegistry, + modelRegistrySettings: ModelRegistrySettings = ModelRegistrySettings(), + circuitBreakerSettings: CircuitBreakerSettings = CircuitBreakerSettings(), + retryPolicySettings: RetryPolicySettings = RetryPolicySettings(), + tokenBudgetSettings: TokenBudgetSettings = TokenBudgetSettings(), + promptSanitizer: PromptSanitizer? = null, + chatMemory: ChatMemory? = null, + conversationIdProvider: ConversationIdProvider = UuidConversationIdProvider(), + job: Job = SupervisorJob(), + scope: CoroutineScope = CoroutineScope(job + Dispatchers.Default), + policyEngine: dev.tramai.core.policy.PolicyEngine? = null, + dlpInterceptor: DlpInterceptor = NoOpDlpInterceptor, + dlpRedactionAuditEmitter: DlpRedactionAuditEmitter = NoOpDlpRedactionAuditEmitter, + toolResultFilteringSettings: ToolResultFilteringSettings = ToolResultFilteringSettings(), + engineEventObserver: EngineEventObserver = NoOpEngineEventObserver, + toolFailureDiagnosticObserver: ToolFailureDiagnosticObserver = NoOpToolFailureDiagnosticObserver, + policyDecisionAuditEmitter: PolicyDecisionAuditEmitter = NoOpPolicyDecisionAuditEmitter, + // Approval suspension dependencies + suspendedInvocationStore: SuspendedInvocationStore = InMemorySuspendedInvocationStore(), + approvalContinuationStore: ApprovalContinuationStore? = null, + toolArgumentsDigester: ToolArgumentsDigester? = null, + approvalGateCoordinator: ApprovalGateCoordinator? = null, + approvalLifecycleAuditEmitter: ApprovalLifecycleAuditEmitter = NoOpApprovalLifecycleAuditEmitter, + clock: Clock = Clock.systemUTC(), +) : this(EngineComponentFactory.create( + providerRegistry, structuredOutputHandler, toolRegistry, operationObserver, operationInterceptor, responseCache, + modelRegistry, modelRegistrySettings, circuitBreakerSettings, retryPolicySettings, tokenBudgetSettings, promptSanitizer, + chatMemory, conversationIdProvider, policyEngine, dlpInterceptor, dlpRedactionAuditEmitter, + toolResultFilteringSettings, engineEventObserver, toolFailureDiagnosticObserver, policyDecisionAuditEmitter, + suspendedInvocationStore, approvalContinuationStore, toolArgumentsDigester, approvalGateCoordinator, + approvalLifecycleAuditEmitter, clock, +)) private val circuitBreaker = ProviderCircuitBreaker(circuitBreakerSettings) private val retryDelayPolicy = ProviderRetryDelayPolicy(retryPolicySettings) private val migrationWarningGuard = java.util.concurrent.atomic.AtomicBoolean(false) - private val resolvedPolicyEngine: PolicyEngine = policyEngine - ?: LegacyPermissivePolicyEngine - private val isLegacyFallback: Boolean = policyEngine == null + private val resolvedPolicyEngine: PolicyEngine = components.security.resolvedPolicyEngine + private val isLegacyFallback: Boolean = components.security.isLegacyFallback private val resumeOperationRegistry: ResumeOperationRegistry = ResumeOperationRegistry() private val closed = java.util.concurrent.atomic.AtomicBoolean(false) private val engineThreadMarker = ThreadLocal() /** * Internally owned lifecycle job and scope. The engine's OWN work (blocking * calls, streaming collections) parents here — never to the caller-supplied - * [job]/[scope] constructor parameters, which remain for ABI compatibility + * legacy job / scope constructor parameters, which remain for ABI compatibility * only. close() cancels and joins [lifecycleJob], so it can prove that * engine-initiated work has terminated, regardless of where the caller's * job lives (and without risking the caller-job join deadlock). @@ -261,8 +293,6 @@ class TramaiEngine( promptSanitizer = promptSanitizer, chatMemory = chatMemory, conversationIdProvider = conversationIdProvider, - job = job, - scope = scope, policyEngine = policyEngine, dlpInterceptor = dlpInterceptor, dlpRedactionAuditEmitter = dlpRedactionAuditEmitter, @@ -316,8 +346,8 @@ class TramaiEngine( approvalGateCoordinator: ApprovalGateCoordinator? = null, approvalLifecycleAuditEmitter: ApprovalLifecycleAuditEmitter = NoOpApprovalLifecycleAuditEmitter, clock: Clock = Clock.systemUTC(), - ) : this( - provider = provider, + ) : this(EngineComponentFactory.create( + providerRegistry = ProviderRegistry.singleProvider(provider), structuredOutputHandler = structuredOutputHandler, toolRegistry = toolRegistry, operationObserver = operationObserver, @@ -331,8 +361,6 @@ class TramaiEngine( promptSanitizer = promptSanitizer, chatMemory = chatMemory, conversationIdProvider = conversationIdProvider, - job = job, - scope = scope, policyEngine = policyEngine, dlpInterceptor = dlpInterceptor, dlpRedactionAuditEmitter = dlpRedactionAuditEmitter, @@ -346,9 +374,8 @@ class TramaiEngine( approvalGateCoordinator = approvalGateCoordinator, approvalLifecycleAuditEmitter = approvalLifecycleAuditEmitter, clock = clock, - ) { - this.structuredOutputFailureDiagnosticObserver = structuredOutputFailureDiagnosticObserver - } + structuredOutputFailureDiagnosticObserver = structuredOutputFailureDiagnosticObserver, + )) /** * Additive configuration: creates an engine from a provider registry with @@ -387,7 +414,7 @@ class TramaiEngine( approvalGateCoordinator: ApprovalGateCoordinator? = null, approvalLifecycleAuditEmitter: ApprovalLifecycleAuditEmitter = NoOpApprovalLifecycleAuditEmitter, clock: Clock = Clock.systemUTC(), - ) : this( + ) : this(EngineComponentFactory.create( providerRegistry = providerRegistry, structuredOutputHandler = structuredOutputHandler, toolRegistry = toolRegistry, @@ -402,8 +429,6 @@ class TramaiEngine( promptSanitizer = promptSanitizer, chatMemory = chatMemory, conversationIdProvider = conversationIdProvider, - job = job, - scope = scope, policyEngine = policyEngine, dlpInterceptor = dlpInterceptor, dlpRedactionAuditEmitter = dlpRedactionAuditEmitter, @@ -417,9 +442,8 @@ class TramaiEngine( approvalGateCoordinator = approvalGateCoordinator, approvalLifecycleAuditEmitter = approvalLifecycleAuditEmitter, clock = clock, - ) { - this.structuredOutputFailureDiagnosticObserver = structuredOutputFailureDiagnosticObserver - } + structuredOutputFailureDiagnosticObserver = structuredOutputFailureDiagnosticObserver, + )) /** * Creates a proxy implementation for the given Tramai service interface. @@ -432,44 +456,17 @@ class TramaiEngine( promptSanitizer = promptSanitizer, ) val handler = TramaiInvocationHandler( - providerRegistry = providerRegistry, - structuredOutputHandler = structuredOutputHandler, - toolRegistry = toolRegistry, - operationObserver = operationObserver, - operationInterceptor = operationInterceptor, - responseCache = responseCache, - modelRegistry = modelRegistry, - modelRegistrySettings = modelRegistrySettings, + components = components, circuitBreaker = circuitBreaker, retryDelayPolicy = retryDelayPolicy, - tokenBudgetSettings = tokenBudgetSettings, - promptSanitizer = promptSanitizer, - chatMemory = chatMemory, - conversationIdProvider = conversationIdProvider, - scope = scope, + migrationWarningGuard = migrationWarningGuard, lifecycleJob = lifecycleJob, lifecycleScope = lifecycleScope, isClosed = closed, engineThreadMarker = engineThreadMarker, activeInvocationJobs = activeInvocationJobs, serviceDefinition = definition, - policyEngine = resolvedPolicyEngine, - migrationWarningGuard = migrationWarningGuard, - isLegacyFallback = isLegacyFallback, - dlpInterceptor = dlpInterceptor, - dlpRedactionAuditEmitter = dlpRedactionAuditEmitter, - toolResultFilteringSettings = toolResultFilteringSettings, - engineEventObserver = engineEventObserver, - toolFailureDiagnosticObserver = toolFailureDiagnosticObserver, - structuredOutputFailureDiagnosticObserver = structuredOutputFailureDiagnosticObserver, - policyDecisionAuditEmitter = policyDecisionAuditEmitter, - suspendedInvocationStore = suspendedInvocationStore, - approvalContinuationStore = approvalContinuationStore, - toolArgumentsDigester = toolArgumentsDigester, - approvalGateCoordinator = approvalGateCoordinator, - approvalLifecycleAuditEmitter = approvalLifecycleAuditEmitter, resumeOperationRegistry = resumeOperationRegistry, - clock = clock, ) @Suppress("UNCHECKED_CAST") @@ -501,44 +498,17 @@ class TramaiEngine( promptSanitizer = promptSanitizer, ) val handler = TramaiInvocationHandler( - providerRegistry = providerRegistry, - structuredOutputHandler = structuredOutputHandler, - toolRegistry = toolRegistry, - operationObserver = operationObserver, - operationInterceptor = operationInterceptor, - responseCache = responseCache, - modelRegistry = modelRegistry, - modelRegistrySettings = modelRegistrySettings, + components = components, circuitBreaker = circuitBreaker, retryDelayPolicy = retryDelayPolicy, - tokenBudgetSettings = tokenBudgetSettings, - promptSanitizer = promptSanitizer, - chatMemory = chatMemory, - conversationIdProvider = conversationIdProvider, - scope = scope, + migrationWarningGuard = migrationWarningGuard, lifecycleJob = lifecycleJob, lifecycleScope = lifecycleScope, isClosed = closed, engineThreadMarker = engineThreadMarker, activeInvocationJobs = activeInvocationJobs, serviceDefinition = definition, - policyEngine = resolvedPolicyEngine, - migrationWarningGuard = migrationWarningGuard, - isLegacyFallback = isLegacyFallback, - dlpInterceptor = dlpInterceptor, - dlpRedactionAuditEmitter = dlpRedactionAuditEmitter, - toolResultFilteringSettings = toolResultFilteringSettings, - engineEventObserver = engineEventObserver, - toolFailureDiagnosticObserver = toolFailureDiagnosticObserver, - structuredOutputFailureDiagnosticObserver = structuredOutputFailureDiagnosticObserver, - policyDecisionAuditEmitter = policyDecisionAuditEmitter, - suspendedInvocationStore = suspendedInvocationStore, - approvalContinuationStore = approvalContinuationStore, - toolArgumentsDigester = toolArgumentsDigester, - approvalGateCoordinator = approvalGateCoordinator, - approvalLifecycleAuditEmitter = approvalLifecycleAuditEmitter, resumeOperationRegistry = resumeOperationRegistry, - clock = clock, ) resumeOperationRegistry.registerAll( serviceDefinition = definition, @@ -586,8 +556,8 @@ class TramaiEngine( /** * Cancels all engine-initiated work and, except from one of the engine's - * own coroutines, waits for it to terminate. The caller-supplied [job] and - * [scope] constructor parameters are NEVER cancelled or joined here — the + * own coroutines, waits for it to terminate. The caller-supplied legacy job / + * scope constructor parameters are NEVER cancelled or joined here — the * engine owns its own [lifecycleJob], so closing cannot deadlock a caller * that passed its current job. Dependencies supplied by callers are not * closed. @@ -648,48 +618,46 @@ data class ResumeApprovalCommand( ) internal class TramaiInvocationHandler( - private val providerRegistry: ProviderRegistry, - private val structuredOutputHandler: StructuredOutputHandler?, - private val toolRegistry: ToolRegistry, - private val operationObserver: OperationObserver, - private val operationInterceptor: OperationInterceptor, - private val responseCache: OperationResponseCache, - private val modelRegistry: ModelRegistry, - private val modelRegistrySettings: ModelRegistrySettings, + private val components: EngineComponents, private val circuitBreaker: ProviderCircuitBreaker, private val retryDelayPolicy: ProviderRetryDelayPolicy, - private val tokenBudgetSettings: TokenBudgetSettings, - private val promptSanitizer: PromptSanitizer?, - private val chatMemory: ChatMemory?, - private val conversationIdProvider: ConversationIdProvider, - private val scope: CoroutineScope, + private val migrationWarningGuard: java.util.concurrent.atomic.AtomicBoolean, private val lifecycleJob: Job, private val lifecycleScope: CoroutineScope, private val isClosed: java.util.concurrent.atomic.AtomicBoolean = java.util.concurrent.atomic.AtomicBoolean(false), private val engineThreadMarker: ThreadLocal = ThreadLocal(), private val activeInvocationJobs: MutableSet = java.util.concurrent.ConcurrentHashMap.newKeySet(), private val serviceDefinition: ServiceDefinition, - policyEngine: PolicyEngine, - private val migrationWarningGuard: java.util.concurrent.atomic.AtomicBoolean, - isLegacyFallback: Boolean, - private val dlpInterceptor: DlpInterceptor, - private val dlpRedactionAuditEmitter: DlpRedactionAuditEmitter, - private val toolResultFilteringSettings: ToolResultFilteringSettings, - private val engineEventObserver: EngineEventObserver, - private val toolFailureDiagnosticObserver: ToolFailureDiagnosticObserver, - private val structuredOutputFailureDiagnosticObserver: StructuredOutputFailureDiagnosticObserver, - private val policyDecisionAuditEmitter: PolicyDecisionAuditEmitter, - // Approval suspension dependencies - private val suspendedInvocationStore: SuspendedInvocationStore, - private val approvalContinuationStore: ApprovalContinuationStore?, - private val toolArgumentsDigester: ToolArgumentsDigester?, - private val approvalGateCoordinator: ApprovalGateCoordinator?, - private val approvalLifecycleAuditEmitter: ApprovalLifecycleAuditEmitter, private val resumeOperationRegistry: ResumeOperationRegistry, - private val clock: Clock, ) : InvocationHandler { - private val policyHelper = PolicyEnforcementHelper(policyEngine, migrationWarningGuard, isLegacyFallback = isLegacyFallback, auditEmitter = policyDecisionAuditEmitter) + private val providerRegistry = components.providers.providerRegistry + private val structuredOutputHandler = components.execution.structuredOutputHandler + private val toolRegistry = components.tools.toolRegistry + private val operationObserver = components.observation.operationObserver + private val operationInterceptor = components.observation.operationInterceptor + private val responseCache = components.persistence.responseCache + private val modelRegistry = components.security.modelRegistry + private val modelRegistrySettings = components.security.modelRegistrySettings + private val tokenBudgetSettings = components.execution.tokenBudgetSettings + private val promptSanitizer = components.security.promptSanitizer + private val chatMemory = components.persistence.chatMemory + private val conversationIdProvider = components.persistence.conversationIdProvider + private val dlpInterceptor = components.security.dlpInterceptor + private val dlpRedactionAuditEmitter = components.security.dlpRedactionAuditEmitter + private val toolResultFilteringSettings = components.tools.toolResultFilteringSettings + private val engineEventObserver = components.observation.engineEventObserver + private val toolFailureDiagnosticObserver = components.observation.toolFailureDiagnosticObserver + private val structuredOutputFailureDiagnosticObserver = components.observation.structuredOutputFailureDiagnosticObserver + private val policyDecisionAuditEmitter = components.security.policyDecisionAuditEmitter + private val suspendedInvocationStore = components.approvals.suspendedInvocationStore + private val approvalLifecycleAuditEmitter = components.approvals.approvalLifecycleAuditEmitter + private val clock = components.execution.clock + private val approvalContinuationStore = (components.approvals.capability as? ApprovalCapability.Enabled)?.continuationStore + private val toolArgumentsDigester = (components.approvals.capability as? ApprovalCapability.Enabled)?.argumentsDigester + private val approvalGateCoordinator = (components.approvals.capability as? ApprovalCapability.Enabled)?.gateCoordinator + + private val policyHelper = PolicyEnforcementHelper(components.security.resolvedPolicyEngine, migrationWarningGuard, isLegacyFallback = components.security.isLegacyFallback, auditEmitter = policyDecisionAuditEmitter) private val modelRegistryEnforcer = ModelRegistryEnforcer(modelRegistry, modelRegistrySettings) private fun OperationObservation.completeCancellation(cancellation: CancellationException) { diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponentFactory.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponentFactory.kt new file mode 100644 index 00000000..4ed297b6 --- /dev/null +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponentFactory.kt @@ -0,0 +1,53 @@ +package dev.tramai.engine.components + +import dev.tramai.core.approval.* +import dev.tramai.core.memory.ChatMemory +import dev.tramai.core.memory.ConversationIdProvider +import dev.tramai.core.model.ModelRegistry +import dev.tramai.core.model.ModelRegistrySettings +import dev.tramai.core.observation.* +import dev.tramai.core.policy.* +import dev.tramai.core.provider.ProviderRegistry +import dev.tramai.core.security.* +import dev.tramai.core.structured.* +import dev.tramai.engine.* +import java.time.Clock + +/** One authoritative composition boundary: validates collaborators and creates the immutable snapshot. */ +internal object EngineComponentFactory { + @Suppress("LongParameterList") + fun create(providerRegistry: ProviderRegistry, structuredOutputHandler: StructuredOutputHandler?, toolRegistry: ToolRegistry, + operationObserver: OperationObserver, operationInterceptor: OperationInterceptor, responseCache: OperationResponseCache, + modelRegistry: ModelRegistry, modelRegistrySettings: ModelRegistrySettings, circuitBreakerSettings: CircuitBreakerSettings, + retryPolicySettings: RetryPolicySettings, tokenBudgetSettings: TokenBudgetSettings, promptSanitizer: PromptSanitizer?, + chatMemory: ChatMemory?, conversationIdProvider: ConversationIdProvider, + policyEngine: PolicyEngine?, dlpInterceptor: DlpInterceptor, dlpRedactionAuditEmitter: DlpRedactionAuditEmitter, + toolResultFilteringSettings: ToolResultFilteringSettings, engineEventObserver: EngineEventObserver, + toolFailureDiagnosticObserver: ToolFailureDiagnosticObserver, policyDecisionAuditEmitter: PolicyDecisionAuditEmitter, + suspendedInvocationStore: SuspendedInvocationStore, approvalContinuationStore: ApprovalContinuationStore?, + toolArgumentsDigester: ToolArgumentsDigester?, approvalGateCoordinator: ApprovalGateCoordinator?, + approvalLifecycleAuditEmitter: ApprovalLifecycleAuditEmitter, clock: Clock, + structuredOutputFailureDiagnosticObserver: StructuredOutputFailureDiagnosticObserver = NoOpStructuredOutputFailureDiagnosticObserver, + ): EngineComponents { + val capability = approvalCapability(approvalContinuationStore, toolArgumentsDigester, approvalGateCoordinator) + val resolvedPolicy = policyEngine ?: LegacyPermissivePolicyEngine + return EngineComponents( + ProviderComponents(providerRegistry), ToolComponents(toolRegistry, toolResultFilteringSettings), + SecurityComponents(resolvedPolicy, policyEngine == null, promptSanitizer, modelRegistry, modelRegistrySettings, dlpInterceptor, dlpRedactionAuditEmitter, policyDecisionAuditEmitter), + ApprovalComponents(suspendedInvocationStore, approvalLifecycleAuditEmitter, capability), + PersistenceComponents(responseCache, chatMemory, conversationIdProvider), + ObservationComponents(operationObserver, operationInterceptor, engineEventObserver, toolFailureDiagnosticObserver, structuredOutputFailureDiagnosticObserver), + ExecutionComponents(structuredOutputHandler, circuitBreakerSettings, retryPolicySettings, tokenBudgetSettings, clock), + ) + } + + fun approvalCapability(continuationStore: ApprovalContinuationStore?, digester: ToolArgumentsDigester?, coordinator: ApprovalGateCoordinator?): ApprovalCapability = when { + continuationStore != null || digester != null || coordinator != null -> { + require(continuationStore != null && digester != null && coordinator != null) { + "Approval suspension requires continuation store, arguments digester, and gate coordinator" + } + ApprovalCapability.Enabled(continuationStore, digester, coordinator) + } + else -> ApprovalCapability.Disabled + } +} diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponents.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponents.kt new file mode 100644 index 00000000..3811b342 --- /dev/null +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/components/EngineComponents.kt @@ -0,0 +1,110 @@ +package dev.tramai.engine.components + +import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalGateCoordinator +import dev.tramai.core.approval.ApprovalLifecycleAuditEmitter +import dev.tramai.core.approval.ToolArgumentsDigester +import dev.tramai.core.memory.ChatMemory +import dev.tramai.core.memory.ConversationIdProvider +import dev.tramai.core.model.ModelRegistry +import dev.tramai.core.model.ModelRegistrySettings +import dev.tramai.core.observation.OperationInterceptor +import dev.tramai.core.observation.OperationObserver +import dev.tramai.core.observation.ToolFailureDiagnosticObserver +import dev.tramai.core.policy.PolicyDecisionAuditEmitter +import dev.tramai.core.policy.PolicyEngine +import dev.tramai.core.provider.ProviderRegistry +import dev.tramai.core.security.DlpInterceptor +import dev.tramai.core.security.DlpRedactionAuditEmitter +import dev.tramai.core.security.PromptSanitizer +import dev.tramai.core.structured.StructuredOutputFailureDiagnosticObserver +import dev.tramai.core.structured.StructuredOutputHandler +import dev.tramai.engine.CircuitBreakerSettings +import dev.tramai.engine.EngineEventObserver +import dev.tramai.engine.OperationResponseCache +import dev.tramai.engine.RetryPolicySettings +import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.engine.TokenBudgetSettings +import dev.tramai.engine.ToolRegistry +import dev.tramai.engine.ToolResultFilteringSettings +import java.time.Clock + +/** + * Immutable, validated runtime configuration snapshot. It is the single frozen + * composition input to an engine: all references are final, it retains no builder, + * and it performs no lazy reads of builder configuration. + */ +internal data class EngineComponents( + val providers: ProviderComponents, + val tools: ToolComponents, + val security: SecurityComponents, + val approvals: ApprovalComponents, + val persistence: PersistenceComponents, + val observation: ObservationComponents, + val execution: ExecutionComponents, +) + +/** Runtime snapshot of provider routing. The snapshot reference is immutable; supplied providers retain their existing ownership and thread-safety contracts. */ +internal data class ProviderComponents(val providerRegistry: ProviderRegistry) + +/** Runtime snapshot of tool resolution and filtering settings. Caller-supplied registries remain caller-owned. */ +internal data class ToolComponents(val toolRegistry: ToolRegistry, val toolResultFilteringSettings: ToolResultFilteringSettings) + +/** Runtime snapshot of security enforcement. Caller-supplied policy, registry, DLP, and audit collaborators remain caller-owned. */ +internal data class SecurityComponents( + val resolvedPolicyEngine: PolicyEngine, + val isLegacyFallback: Boolean, + val promptSanitizer: PromptSanitizer?, + val modelRegistry: ModelRegistry, + val modelRegistrySettings: ModelRegistrySettings, + val dlpInterceptor: DlpInterceptor, + val dlpRedactionAuditEmitter: DlpRedactionAuditEmitter, + val policyDecisionAuditEmitter: PolicyDecisionAuditEmitter, +) + +/** Explicit approval capability: partial approval state is unrepresentable. */ +internal sealed interface ApprovalCapability { + data object Disabled : ApprovalCapability + data class Enabled( + val continuationStore: ApprovalContinuationStore, + val argumentsDigester: ToolArgumentsDigester, + val gateCoordinator: ApprovalGateCoordinator, + ) : ApprovalCapability +} + +/** Approval collaborators used by the engine. Caller-supplied stores and emitters remain caller-owned. */ +internal data class ApprovalComponents( + val suspendedInvocationStore: SuspendedInvocationStore, + val approvalLifecycleAuditEmitter: ApprovalLifecycleAuditEmitter, + val capability: ApprovalCapability, +) + +/** Runtime snapshot of persistence. Caller-supplied cache and memory remain caller-owned. */ +internal data class PersistenceComponents( + val responseCache: OperationResponseCache, + val chatMemory: ChatMemory?, + val conversationIdProvider: ConversationIdProvider, +) + +/** Runtime snapshot of observation hooks. Caller-supplied observers and interceptors remain caller-owned. */ +internal data class ObservationComponents( + val operationObserver: OperationObserver, + val operationInterceptor: OperationInterceptor, + val engineEventObserver: EngineEventObserver, + val toolFailureDiagnosticObserver: ToolFailureDiagnosticObserver, + val structuredOutputFailureDiagnosticObserver: StructuredOutputFailureDiagnosticObserver, +) + +/** + * Runtime snapshot of execution mechanics. Engine execution parents to its internally owned + * lifecycle job/scope (PR #226 lifecycle model); the legacy job/scope constructor parameters + * of [dev.tramai.engine.TramaiEngine] exist for ABI compatibility only and never cross into + * this snapshot. + */ +internal data class ExecutionComponents( + val structuredOutputHandler: StructuredOutputHandler?, + val circuitBreakerSettings: CircuitBreakerSettings, + val retryPolicySettings: RetryPolicySettings, + val tokenBudgetSettings: TokenBudgetSettings, + val clock: Clock, +) diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/ApprovalEngineEdgeCaseTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/ApprovalEngineEdgeCaseTest.kt index 4e60ca9f..4824f47b 100644 --- a/tramai-engine/src/test/kotlin/dev/tramai/engine/ApprovalEngineEdgeCaseTest.kt +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/ApprovalEngineEdgeCaseTest.kt @@ -891,33 +891,24 @@ class ApprovalEngineEdgeCaseTest { } @Test - fun `missing digester does not consume token`() { - // Engine without digester — suspension will fail early because - // tool execution requires a digester for approval binding validation. + fun `missing digester fails at construction`() { + // Epic 2.1: partial approval composition (continuation store + coordinator + // without a digester) is unrepresentable in the component model and is + // rejected at engine construction — not lazily at tool execution. val coordinator = ConfigurableApprovalGateCoordinator() - val engine = TramaiEngine( - provider = provider, - toolRegistry = toolRegistry, - policyEngine = policyEngine, - suspendedInvocationStore = suspendedInvocationStore, - approvalContinuationStore = continuationStore, - // No digester - approvalGateCoordinator = coordinator, - clock = fixedClock, - ) - - val service = engine.create() - - // The initial tool execution should fail with ConfigurationException - // because the digester is required for approval binding validation. assertThatThrownBy { - runBlocking { service.execute("input") } - }.isInstanceOf(dev.tramai.core.exception.ConfigurationException::class.java) - .hasMessageContaining("ToolArgumentsDigester") - - // Coordinator was NEVER called — suspension failed before any approval - assertThat(coordinator.lastCreateCommand).isNull() - assertThat(coordinator.lastAuthorizeCommand).isNull() + TramaiEngine( + provider = provider, + toolRegistry = toolRegistry, + policyEngine = policyEngine, + suspendedInvocationStore = suspendedInvocationStore, + approvalContinuationStore = continuationStore, + // No digester + approvalGateCoordinator = coordinator, + clock = fixedClock, + ) + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessage("Approval suspension requires continuation store, arguments digester, and gate coordinator") } @Test diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/EngineComponentsTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/EngineComponentsTest.kt new file mode 100644 index 00000000..a3eae6ff --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/EngineComponentsTest.kt @@ -0,0 +1,227 @@ +package dev.tramai.engine + +import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalGateCoordinator +import dev.tramai.core.approval.ApprovalLifecycleAuditEmitter +import dev.tramai.core.approval.NoOpApprovalLifecycleAuditEmitter +import dev.tramai.core.approval.ToolArgumentsDigester +import dev.tramai.core.memory.ChatMemory +import dev.tramai.core.memory.ConversationIdProvider +import dev.tramai.core.memory.UuidConversationIdProvider +import dev.tramai.core.model.ModelRegistry +import dev.tramai.core.model.ModelRegistrySettings +import dev.tramai.core.model.ModelRequest +import dev.tramai.core.model.ModelResponse +import dev.tramai.core.model.NoOpModelRegistry +import dev.tramai.core.observation.NoOpOperationInterceptor +import dev.tramai.core.observation.NoOpOperationObserver +import dev.tramai.core.observation.NoOpToolFailureDiagnosticObserver +import dev.tramai.core.observation.OperationInterceptor +import dev.tramai.core.observation.OperationObserver +import dev.tramai.core.observation.ToolFailureDiagnosticObserver +import dev.tramai.core.policy.NoOpPolicyDecisionAuditEmitter +import dev.tramai.core.policy.PolicyDecisionAuditEmitter +import dev.tramai.core.policy.PolicyEngine +import dev.tramai.core.provider.ModelProvider +import dev.tramai.core.provider.ProviderRegistry +import dev.tramai.core.security.DlpInterceptor +import dev.tramai.core.security.DlpRedactionAuditEmitter +import dev.tramai.core.security.NoOpDlpInterceptor +import dev.tramai.core.security.NoOpDlpRedactionAuditEmitter +import dev.tramai.core.security.PromptSanitizer +import dev.tramai.core.structured.StructuredOutputHandler +import dev.tramai.engine.components.ApprovalCapability +import dev.tramai.engine.components.EngineComponentFactory +import dev.tramai.engine.components.EngineComponents +import java.lang.reflect.Proxy +import java.time.Clock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class EngineComponentsTest { + @Test + fun `approval disabled is explicit`() { + assertIs(EngineComponentFactory.approvalCapability(null, null, null)) + } + + @Test + fun `complete approval capability is accepted`() { + val store = collaborator() + val digester = collaborator() + val coordinator = collaborator() + + val capability = assertIs( + EngineComponentFactory.approvalCapability(store, digester, coordinator), + ) + + assertSame(store, capability.continuationStore) + assertSame(digester, capability.argumentsDigester) + assertSame(coordinator, capability.gateCoordinator) + } + + @Test + fun `every partial approval combination fails at the composition boundary`() { + val store = collaborator() + val digester = collaborator() + val coordinator = collaborator() + val partials = listOf( + Triple(store, null, null), Triple(null, digester, null), Triple(null, null, coordinator), + Triple(store, digester, null), Triple(store, null, coordinator), Triple(null, digester, coordinator), + ) + + partials.forEach { (continuationStore, argumentsDigester, gateCoordinator) -> + val failure = runCatching { + EngineComponentFactory.approvalCapability(continuationStore, argumentsDigester, gateCoordinator) + }.exceptionOrNull() + assertIs(failure) + assertEquals( + "Approval suspension requires continuation store, arguments digester, and gate coordinator", + failure.message, + ) + } + } + + @Test + fun `creates a complete default component snapshot`() { + val registry = ProviderRegistry.singleProvider(registryTestProvider()) + val components = EngineComponentFactory.create( + providerRegistry = registry, + structuredOutputHandler = null, + toolRegistry = ToolRegistry(), + operationObserver = NoOpOperationObserver, + operationInterceptor = NoOpOperationInterceptor, + responseCache = NoOpOperationResponseCache, + modelRegistry = NoOpModelRegistry, + modelRegistrySettings = ModelRegistrySettings(), + circuitBreakerSettings = CircuitBreakerSettings(), + retryPolicySettings = RetryPolicySettings(), + tokenBudgetSettings = TokenBudgetSettings(), + promptSanitizer = null, + chatMemory = null, + conversationIdProvider = UuidConversationIdProvider(), + policyEngine = null, + dlpInterceptor = NoOpDlpInterceptor, + dlpRedactionAuditEmitter = NoOpDlpRedactionAuditEmitter, + toolResultFilteringSettings = ToolResultFilteringSettings(), + engineEventObserver = NoOpEngineEventObserver, + toolFailureDiagnosticObserver = NoOpToolFailureDiagnosticObserver, + policyDecisionAuditEmitter = NoOpPolicyDecisionAuditEmitter, + suspendedInvocationStore = InMemorySuspendedInvocationStore(), + approvalContinuationStore = null, + toolArgumentsDigester = null, + approvalGateCoordinator = null, + approvalLifecycleAuditEmitter = NoOpApprovalLifecycleAuditEmitter, + clock = Clock.systemUTC(), + ) + + assertSame(registry, components.providers.providerRegistry) + assertIs(components.approvals.capability) + assertTrue(components.security.isLegacyFallback) + assertSame(LegacyPermissivePolicyEngine, components.security.resolvedPolicyEngine) + assertSame(NoOpOperationObserver, components.observation.operationObserver) + assertSame(NoOpOperationResponseCache, components.persistence.responseCache) + } + + @Test + fun `policy resolution happens once at construction`() { + val customPolicy = collaborator() + val components = createComponents(policyEngine = customPolicy) + + assertFalse(components.security.isLegacyFallback) + assertSame(customPolicy, components.security.resolvedPolicyEngine) + } + + @Test + fun `component snapshot holds final references to the supplied collaborators`() { + val store = collaborator() + val digester = collaborator() + val coordinator = collaborator() + val suspended = InMemorySuspendedInvocationStore() + + val components = createComponents( + suspendedInvocationStore = suspended, + approvalContinuationStore = store, + toolArgumentsDigester = digester, + approvalGateCoordinator = coordinator, + ) + + val enabled = assertIs(components.approvals.capability) + assertSame(store, enabled.continuationStore) + assertSame(digester, enabled.argumentsDigester) + assertSame(coordinator, enabled.gateCoordinator) + assertSame(suspended, components.approvals.suspendedInvocationStore) + } + + private fun createComponents( + providerRegistry: ProviderRegistry = ProviderRegistry.singleProvider(registryTestProvider()), + structuredOutputHandler: StructuredOutputHandler? = null, + toolRegistry: ToolRegistry = ToolRegistry(), + operationObserver: OperationObserver = NoOpOperationObserver, + operationInterceptor: OperationInterceptor = NoOpOperationInterceptor, + responseCache: OperationResponseCache = NoOpOperationResponseCache, + modelRegistry: ModelRegistry = NoOpModelRegistry, + modelRegistrySettings: ModelRegistrySettings = ModelRegistrySettings(), + circuitBreakerSettings: CircuitBreakerSettings = CircuitBreakerSettings(), + retryPolicySettings: RetryPolicySettings = RetryPolicySettings(), + tokenBudgetSettings: TokenBudgetSettings = TokenBudgetSettings(), + promptSanitizer: PromptSanitizer? = null, + chatMemory: ChatMemory? = null, + conversationIdProvider: ConversationIdProvider = UuidConversationIdProvider(), + policyEngine: PolicyEngine? = null, + dlpInterceptor: DlpInterceptor = NoOpDlpInterceptor, + dlpRedactionAuditEmitter: DlpRedactionAuditEmitter = NoOpDlpRedactionAuditEmitter, + toolResultFilteringSettings: ToolResultFilteringSettings = ToolResultFilteringSettings(), + engineEventObserver: EngineEventObserver = NoOpEngineEventObserver, + toolFailureDiagnosticObserver: ToolFailureDiagnosticObserver = NoOpToolFailureDiagnosticObserver, + policyDecisionAuditEmitter: PolicyDecisionAuditEmitter = NoOpPolicyDecisionAuditEmitter, + suspendedInvocationStore: SuspendedInvocationStore = InMemorySuspendedInvocationStore(), + approvalContinuationStore: ApprovalContinuationStore? = null, + toolArgumentsDigester: ToolArgumentsDigester? = null, + approvalGateCoordinator: ApprovalGateCoordinator? = null, + approvalLifecycleAuditEmitter: ApprovalLifecycleAuditEmitter = NoOpApprovalLifecycleAuditEmitter, + clock: Clock = Clock.systemUTC(), + ): EngineComponents = EngineComponentFactory.create( + providerRegistry = providerRegistry, + structuredOutputHandler = structuredOutputHandler, + toolRegistry = toolRegistry, + operationObserver = operationObserver, + operationInterceptor = operationInterceptor, + responseCache = responseCache, + modelRegistry = modelRegistry, + modelRegistrySettings = modelRegistrySettings, + circuitBreakerSettings = circuitBreakerSettings, + retryPolicySettings = retryPolicySettings, + tokenBudgetSettings = tokenBudgetSettings, + promptSanitizer = promptSanitizer, + chatMemory = chatMemory, + conversationIdProvider = conversationIdProvider, + policyEngine = policyEngine, + dlpInterceptor = dlpInterceptor, + dlpRedactionAuditEmitter = dlpRedactionAuditEmitter, + toolResultFilteringSettings = toolResultFilteringSettings, + engineEventObserver = engineEventObserver, + toolFailureDiagnosticObserver = toolFailureDiagnosticObserver, + policyDecisionAuditEmitter = policyDecisionAuditEmitter, + suspendedInvocationStore = suspendedInvocationStore, + approvalContinuationStore = approvalContinuationStore, + toolArgumentsDigester = toolArgumentsDigester, + approvalGateCoordinator = approvalGateCoordinator, + approvalLifecycleAuditEmitter = approvalLifecycleAuditEmitter, + clock = clock, + ) + + private fun registryTestProvider(): ModelProvider = object : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = ModelResponse(content = "test") + override fun providerId(): String = "test-provider" + } + + private inline fun collaborator(): T { + assertTrue(T::class.java.isInterface) + @Suppress("UNCHECKED_CAST") + return Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, _, _ -> error("unused") } as T + } +} diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/TrustedReplayEnvelopeRegistryTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/TrustedReplayEnvelopeRegistryTest.kt index 33d03b54..593696a8 100644 --- a/tramai-engine/src/test/kotlin/dev/tramai/engine/TrustedReplayEnvelopeRegistryTest.kt +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/TrustedReplayEnvelopeRegistryTest.kt @@ -1,6 +1,7 @@ package dev.tramai.engine import dev.tramai.core.approval.Sha256Digest +import dev.tramai.engine.components.EngineComponentFactory import dev.tramai.core.memory.UuidConversationIdProvider import dev.tramai.core.model.Message import dev.tramai.core.model.MessageRole @@ -369,7 +370,7 @@ class TrustedReplayEnvelopeRegistryTest { /** Creates a [TramaiInvocationHandler] with minimal viable defaults (never dereferenced during registration). */ private fun dummyHandler(svcDef: ServiceDefinition, registry: ResumeOperationRegistry): TramaiInvocationHandler { - return TramaiInvocationHandler( + val components = EngineComponentFactory.create( providerRegistry = ProviderRegistry.builder().build(), structuredOutputHandler = null, toolRegistry = ToolRegistry(), @@ -378,33 +379,35 @@ class TrustedReplayEnvelopeRegistryTest { responseCache = NoOpOperationResponseCache, modelRegistry = dev.tramai.core.model.NoOpModelRegistry, modelRegistrySettings = dev.tramai.core.model.ModelRegistrySettings(), - circuitBreaker = ProviderCircuitBreaker(CircuitBreakerSettings()), - retryDelayPolicy = ProviderRetryDelayPolicy(RetryPolicySettings()), + circuitBreakerSettings = CircuitBreakerSettings(), + retryPolicySettings = RetryPolicySettings(), tokenBudgetSettings = TokenBudgetSettings(), promptSanitizer = null, chatMemory = null, conversationIdProvider = UuidConversationIdProvider(), - scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), - serviceDefinition = svcDef, policyEngine = PolicyEngine { _ -> PolicyDecision.Allow }, - migrationWarningGuard = AtomicBoolean(false), - isLegacyFallback = false, dlpInterceptor = NoOpDlpInterceptor, dlpRedactionAuditEmitter = NoOpDlpRedactionAuditEmitter, toolResultFilteringSettings = ToolResultFilteringSettings(), engineEventObserver = NoOpEngineEventObserver, toolFailureDiagnosticObserver = NoOpToolFailureDiagnosticObserver, - structuredOutputFailureDiagnosticObserver = dev.tramai.core.structured.NoOpStructuredOutputFailureDiagnosticObserver, policyDecisionAuditEmitter = NoOpPolicyDecisionAuditEmitter, suspendedInvocationStore = InMemorySuspendedInvocationStore(), approvalContinuationStore = null, toolArgumentsDigester = null, approvalGateCoordinator = null, approvalLifecycleAuditEmitter = NoOpApprovalLifecycleAuditEmitter, - resumeOperationRegistry = registry, + clock = Clock.systemUTC(), + ) + return TramaiInvocationHandler( + components = components, + circuitBreaker = ProviderCircuitBreaker(CircuitBreakerSettings()), + retryDelayPolicy = ProviderRetryDelayPolicy(RetryPolicySettings()), + migrationWarningGuard = AtomicBoolean(false), lifecycleJob = kotlinx.coroutines.SupervisorJob(), lifecycleScope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default), - clock = Clock.systemUTC(), + serviceDefinition = svcDef, + resumeOperationRegistry = registry, ) } } diff --git a/tramai-standalone/src/main/kotlin/dev/tramai/standalone/Tramai.kt b/tramai-standalone/src/main/kotlin/dev/tramai/standalone/Tramai.kt index b389c71f..a81fbb2b 100644 --- a/tramai-standalone/src/main/kotlin/dev/tramai/standalone/Tramai.kt +++ b/tramai-standalone/src/main/kotlin/dev/tramai/standalone/Tramai.kt @@ -479,9 +479,15 @@ class Tramai private constructor( * * @throws IllegalStateException if approval composition is partially configured * (continuation store, digester, and coordinator must all be set or all be null). + * Note: the engine component boundary reports the same misconfiguration as + * [IllegalArgumentException]; this guard intentionally preserves the historical + * builder contract until the lazy-materialization compatibility guard is retired. */ fun build(): Tramai { - // Approval composition must be complete or absent + // Compatibility pre-flight: Tramai materializes its engine lazily, but partial + // approval composition has historically failed from Builder.build(). + // EngineComponentFactory remains the authoritative runtime composition boundary. + // check() (IllegalStateException) preserves the pre-#228 public contract. val hasContinuation = approvalContinuationStore != null val hasDigester = toolArgumentsDigester != null val hasCoordinator = approvalGateCoordinator != null diff --git a/tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiComponentCompositionTest.kt b/tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiComponentCompositionTest.kt new file mode 100644 index 00000000..3646df3d --- /dev/null +++ b/tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiComponentCompositionTest.kt @@ -0,0 +1,149 @@ +package dev.tramai.standalone + +import dev.tramai.core.annotations.AiService +import dev.tramai.core.annotations.Operation +import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalGateCoordinator +import dev.tramai.core.approval.ToolArgumentsDigester +import dev.tramai.core.model.ModelRequest +import dev.tramai.core.model.ModelResponse +import dev.tramai.core.observation.NoOpOperationObservation +import dev.tramai.core.observation.OperationObserver +import dev.tramai.core.provider.ModelProvider +import java.lang.reflect.Proxy +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +/** + * Proves that the standalone composition path produces the same immutable, + * validated runtime snapshot as the engine component model — with the public + * builder API unchanged. + */ +class TramaiComponentCompositionTest { + + @Test + fun `partial approval configuration fails at build`() { + val store = collaborator() + val digester = collaborator() + val coordinator = collaborator() + + val partials = listOf( + Triple(store, null, null), + Triple(null, digester, null), + Triple(null, null, coordinator), + Triple(store, digester, null), + Triple(store, null, coordinator), + Triple(null, digester, coordinator), + ) + + partials.forEach { (continuationStore, argumentsDigester, gateCoordinator) -> + val builder = baseBuilder() + if (continuationStore != null) builder.approvalContinuationStore(continuationStore) + if (argumentsDigester != null) builder.toolArgumentsDigester(argumentsDigester) + if (gateCoordinator != null) builder.approvalGateCoordinator(gateCoordinator) + + assertThatThrownBy { builder.build() } + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("Approval suspension requires continuation store, arguments digester, and gate coordinator") + } + } + + @Test + fun `complete approval configuration builds and materializes the runtime`() { + val tramai = baseBuilder() + .approvalContinuationStore(collaborator()) + .toolArgumentsDigester(collaborator()) + .approvalGateCoordinator(collaborator()) + .build() + + // The capability wiring through the public bridge must not fail. + assertThat(tramai.runtime()).isNotNull() + } + + @Test + fun `builder mutation after build cannot mutate the built runtime`() { + val providerACalls = AtomicInteger() + val providerA = object : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = + ModelResponse(content = "provider-a:${providerACalls.incrementAndGet()}") + + override fun providerId(): String = "mock" + } + val observerACalls = AtomicInteger() + val observerA = OperationObserver { + observerACalls.incrementAndGet() + NoOpOperationObservation + } + + val builder = baseBuilder() + .provider(providerA, default = true) + .observer(observerA) + + val runtimeA = builder.build() + + // Mutate the builder after build — must not affect the built runtime. + val providerBCalls = AtomicInteger() + val providerB = object : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = + ModelResponse(content = "provider-b:${providerBCalls.incrementAndGet()}") + + override fun providerId(): String = "other" + } + builder + .provider(providerB, default = true) + .observer(OperationObserver { NoOpOperationObservation }) + + val service = runtimeA.create() + val result = runBlocking { service.greet("world") } + + assertThat(result).isEqualTo("provider-a:1") + assertThat(providerBCalls.get()).isZero() + assertThat(observerACalls.get()).isEqualTo(1) + } + + @Test + fun `no-op default behaviour remains unchanged`() { + val tramai = baseBuilder().build() + + val service = tramai.create() + val result = runBlocking { service.greet("world") } + + assertThat(result).isEqualTo("hello") + } + + @Test + fun `legacy policy behaviour remains unchanged`() { + // No policyEngine configured → permissive legacy fallback, call succeeds. + val tramai = baseBuilder().build() + + val service = tramai.create() + assertThat(runBlocking { service.greet("world") }).isEqualTo("hello") + } + + @AiService + private interface GreetingService { + @Operation(model = "mock-model") + suspend fun greet(name: String): String + } + + private fun baseBuilder(): Tramai.Builder = Tramai.builder() + .provider( + object : ModelProvider { + override suspend fun complete(request: ModelRequest): ModelResponse = + ModelResponse(content = "hello") + + override fun providerId(): String = "mock" + }, + default = true, + ) + .model("mock-model", "mock") + + private inline fun collaborator(): T { + assertThat(T::class.java.isInterface).isTrue() + @Suppress("UNCHECKED_CAST") + return Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, _, _ -> error("unused") } as T + } +}