Merge remote-tracking branch 'origin/feat/session-robustness-and-dox' into feat/session-robustness-and-dox
This commit is contained in:
@@ -11,13 +11,14 @@ All sources under `apps/server/src/`.
|
|||||||
## Local Contracts
|
## Local Contracts
|
||||||
|
|
||||||
### HTTP REST routes
|
### HTTP REST routes
|
||||||
- `GET/POST /sessions` — session browse and start
|
- `GET/POST /sessions` — session browse and start (`POST` accepts an optional `intent` brief, parity with the WS `StartSession`)
|
||||||
- `POST /sessions/{id}/resume` — resume a session after restart
|
- `POST /sessions/{id}/resume` — resume a session after restart
|
||||||
- `GET/POST /tasks` — task listing and management
|
- `GET/POST /tasks` — task listing and management
|
||||||
- `GET/POST /providers` — provider configuration
|
- `GET/POST /providers` — provider configuration
|
||||||
- `GET/POST /workflows` — workflow management
|
- `GET/POST /workflows` — workflow management
|
||||||
- `GET /health` — health report (probes: event-store, llama-server, disk watermark)
|
- `GET /health` — health report (probes: event-store, llama-server, disk watermark)
|
||||||
- `GET /stats` — metrics report (MetricsProjection)
|
- `GET /stats` — metrics report (MetricsProjection)
|
||||||
|
- `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing
|
||||||
|
|
||||||
### WebSocket protocol (`/ws`)
|
### WebSocket protocol (`/ws`)
|
||||||
- **ServerMessage** (server → client): sealed hierarchy — `SessionMessage` (event-derived, carries `sequence` + `sessionSequence`) and `NonEventMessage` (control/infra). Variants include session lifecycle, approval requests, clarification requests, narration, proposed workflows, health/metrics pushes.
|
- **ServerMessage** (server → client): sealed hierarchy — `SessionMessage` (event-derived, carries `sequence` + `sessionSequence`) and `NonEventMessage` (control/infra). Variants include session lifecycle, approval requests, clarification requests, narration, proposed workflows, health/metrics pushes.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ dependencies {
|
|||||||
implementation project(':infrastructure:persistence')
|
implementation project(':infrastructure:persistence')
|
||||||
implementation project(':infrastructure:inference')
|
implementation project(':infrastructure:inference')
|
||||||
implementation project(':infrastructure:inference:llama_cpp')
|
implementation project(':infrastructure:inference:llama_cpp')
|
||||||
|
implementation project(':infrastructure:inference:openai_compat')
|
||||||
implementation project(':infrastructure:inference:commons')
|
implementation project(':infrastructure:inference:commons')
|
||||||
implementation project(':core:router')
|
implementation project(':core:router')
|
||||||
implementation project(':core:tools')
|
implementation project(':core:tools')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.correx.apps.server
|
package com.correx.apps.server
|
||||||
|
|
||||||
import com.correx.apps.server.health.HealthInspectionService
|
import com.correx.apps.server.health.HealthInspectionService
|
||||||
|
import com.correx.apps.server.metrics.ToolReliabilityInspectionService
|
||||||
import com.correx.apps.server.routes.providerRoutes
|
import com.correx.apps.server.routes.providerRoutes
|
||||||
import com.correx.apps.server.routes.sessionRoutes
|
import com.correx.apps.server.routes.sessionRoutes
|
||||||
import com.correx.apps.server.routes.taskRoutes
|
import com.correx.apps.server.routes.taskRoutes
|
||||||
@@ -38,6 +39,7 @@ fun Application.configureServer(module: ServerModule) {
|
|||||||
|
|
||||||
val globalStreamHandler = GlobalStreamHandler(module)
|
val globalStreamHandler = GlobalStreamHandler(module)
|
||||||
val healthInspection = HealthInspectionService(module.eventStore)
|
val healthInspection = HealthInspectionService(module.eventStore)
|
||||||
|
val toolReliability = ToolReliabilityInspectionService(module.eventStore)
|
||||||
|
|
||||||
routing {
|
routing {
|
||||||
get("/health") {
|
get("/health") {
|
||||||
@@ -51,6 +53,12 @@ fun Application.configureServer(module: ServerModule) {
|
|||||||
call.respond(healthInspection.inspect())
|
call.respond(healthInspection.inspect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-model tool-call reliability across the whole event log (valid vs. failed tool calls
|
||||||
|
// per provider). Measurement groundwork for capability-aware routing of tool-heavy stages.
|
||||||
|
get("/metrics/tool-reliability") {
|
||||||
|
call.respond(toolReliability.inspect())
|
||||||
|
}
|
||||||
|
|
||||||
webSocket("/stream") {
|
webSocket("/stream") {
|
||||||
globalStreamHandler.handle(this)
|
globalStreamHandler.handle(this)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ import com.correx.core.events.types.SessionId
|
|||||||
import com.correx.infrastructure.InfrastructureModule
|
import com.correx.infrastructure.InfrastructureModule
|
||||||
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
||||||
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
|
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
|
||||||
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
|
import com.correx.infrastructure.inference.CapabilityAwareRoutingStrategy
|
||||||
import com.correx.infrastructure.inference.commons.ManagedInferenceRouter
|
import com.correx.infrastructure.inference.commons.ManagedInferenceRouter
|
||||||
import com.correx.infrastructure.inference.commons.AmdResourceProbe
|
import com.correx.infrastructure.inference.commons.AmdResourceProbe
|
||||||
import com.correx.infrastructure.inference.commons.NvidiaResourceProbe
|
import com.correx.infrastructure.inference.commons.NvidiaResourceProbe
|
||||||
@@ -84,7 +84,6 @@ import com.correx.infrastructure.inference.commons.ResourceProbe
|
|||||||
import com.correx.infrastructure.inference.commons.SystemResourceProbe
|
import com.correx.infrastructure.inference.commons.SystemResourceProbe
|
||||||
import com.correx.infrastructure.inference.commons.UnavailableProbe
|
import com.correx.infrastructure.inference.commons.UnavailableProbe
|
||||||
import com.correx.core.inference.InferenceProvider
|
import com.correx.core.inference.InferenceProvider
|
||||||
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
|
||||||
import com.correx.infrastructure.tools.DispatchingToolExecutor
|
import com.correx.infrastructure.tools.DispatchingToolExecutor
|
||||||
import com.correx.infrastructure.tools.task.TaskTools
|
import com.correx.infrastructure.tools.task.TaskTools
|
||||||
import com.correx.infrastructure.tools.FileEditConfig
|
import com.correx.infrastructure.tools.FileEditConfig
|
||||||
@@ -148,12 +147,9 @@ fun main() {
|
|||||||
firstProvider = managedProvider
|
firstProvider = managedProvider
|
||||||
infraRegistry = InfrastructureModule.createProviderRegistry(listOf(managedProvider))
|
infraRegistry = InfrastructureModule.createProviderRegistry(listOf(managedProvider))
|
||||||
// Also register any static providers from [[providers]] (non-managed remotes)
|
// Also register any static providers from [[providers]] (non-managed remotes)
|
||||||
correxConfig.providers.mapNotNull { providerConfig ->
|
correxConfig.providers
|
||||||
when (providerConfig.type.lowercase()) {
|
.mapNotNull { providerConfig -> buildProviderFromConfig(providerConfig) }
|
||||||
"llamacpp" -> buildProviderFromConfig(providerConfig)
|
.forEach { infraRegistry.register(it) }
|
||||||
else -> { log.error("Unknown provider type: {}", providerConfig.type); null }
|
|
||||||
}
|
|
||||||
}.forEach { infraRegistry.register(it) }
|
|
||||||
// Managed router owns per-stage model selection: it ensures the resolved model is resident
|
// Managed router owns per-stage model selection: it ensures the resolved model is resident
|
||||||
// before routing (stage.modelId > capability match > default), evicting the prior model.
|
// before routing (stage.modelId > capability match > default), evicting the prior model.
|
||||||
val managedRouter = ManagedInferenceRouter(modelManager, descriptors, targetModelConfig.id)
|
val managedRouter = ManagedInferenceRouter(modelManager, descriptors, targetModelConfig.id)
|
||||||
@@ -174,7 +170,7 @@ fun main() {
|
|||||||
require(staticProviders.isNotEmpty()) { "At least one provider must be configured" }
|
require(staticProviders.isNotEmpty()) { "At least one provider must be configured" }
|
||||||
firstProvider = staticProviders.first()
|
firstProvider = staticProviders.first()
|
||||||
infraRegistry = InfrastructureModule.createProviderRegistry(staticProviders)
|
infraRegistry = InfrastructureModule.createProviderRegistry(staticProviders)
|
||||||
inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy())
|
inferenceRouter = DefaultInferenceRouter(infraRegistry, CapabilityAwareRoutingStrategy())
|
||||||
llamaBaseUrl = correxConfig.providers.firstOrNull()?.url
|
llamaBaseUrl = correxConfig.providers.firstOrNull()?.url
|
||||||
?: System.getenv("CORREX_LLAMA_URL")
|
?: System.getenv("CORREX_LLAMA_URL")
|
||||||
?: "http://127.0.0.1:10000"
|
?: "http://127.0.0.1:10000"
|
||||||
@@ -566,7 +562,9 @@ fun main() {
|
|||||||
module.start()
|
module.start()
|
||||||
log.info("==============================")
|
log.info("==============================")
|
||||||
|
|
||||||
embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true)
|
embeddedServer(Netty, port = correxConfig.server.port, host = correxConfig.server.host) {
|
||||||
|
configureServer(module)
|
||||||
|
}.start(wait = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun renderProjectProfileText(profile: com.correx.core.sessions.BoundProjectProfile?): String? {
|
fun renderProjectProfileText(profile: com.correx.core.sessions.BoundProjectProfile?): String? {
|
||||||
@@ -651,18 +649,10 @@ private fun resolveConfigRelativePath(raw: String, configDir: Path?): Path {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildProviders(config: CorrexConfig): List<LlamaCppInferenceProvider> {
|
private fun buildProviders(config: CorrexConfig): List<InferenceProvider> {
|
||||||
return if (config.providers.isNotEmpty()) {
|
return if (config.providers.isNotEmpty()) {
|
||||||
log.info("Loading {} provider(s) from config", config.providers.size)
|
log.info("Loading {} provider(s) from config", config.providers.size)
|
||||||
config.providers.mapNotNull { providerConfig ->
|
config.providers.mapNotNull { providerConfig -> buildProviderFromConfig(providerConfig) }
|
||||||
when (providerConfig.type.lowercase()) {
|
|
||||||
"llamacpp" -> buildProviderFromConfig(providerConfig)
|
|
||||||
else -> {
|
|
||||||
log.error("Unknown provider type: {}", providerConfig.type)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
log.info("No providers in config; using env var fallback")
|
log.info("No providers in config; using env var fallback")
|
||||||
listOf(
|
listOf(
|
||||||
@@ -675,14 +665,48 @@ private fun buildProviders(config: CorrexConfig): List<LlamaCppInferenceProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildProviderFromConfig(config: ProviderConfig): LlamaCppInferenceProvider {
|
// Single dispatch from a [[providers]] entry to a concrete InferenceProvider.
|
||||||
|
private fun buildProviderFromConfig(config: ProviderConfig): InferenceProvider? {
|
||||||
val capabilities = parseCapabilitiesFromConfig(config.capabilities)
|
val capabilities = parseCapabilitiesFromConfig(config.capabilities)
|
||||||
return InfrastructureModule.createLlamaCppProvider(
|
return when (config.type.lowercase()) {
|
||||||
modelId = config.modelId,
|
"llamacpp" -> InfrastructureModule.createLlamaCppProvider(
|
||||||
modelPath = config.modelPath,
|
modelId = config.modelId,
|
||||||
baseUrl = config.url,
|
modelPath = config.modelPath,
|
||||||
capabilities = capabilities,
|
baseUrl = config.url,
|
||||||
)
|
capabilities = capabilities,
|
||||||
|
)
|
||||||
|
|
||||||
|
"nim", "openai" -> {
|
||||||
|
val apiKey = resolveApiKey(config)
|
||||||
|
if (apiKey.isBlank()) {
|
||||||
|
log.error(
|
||||||
|
"Provider '{}' (type {}) has no API key (set api_key or api_key_env); skipping",
|
||||||
|
config.id, config.type,
|
||||||
|
)
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
InfrastructureModule.createOpenAiCompatProvider(
|
||||||
|
modelId = config.modelId,
|
||||||
|
baseUrl = config.url,
|
||||||
|
apiKey = apiKey,
|
||||||
|
idPrefix = config.type.lowercase(),
|
||||||
|
capabilities = capabilities,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
log.error("Unknown provider type: {}", config.type)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the literal api_key; otherwise read the named env var. Returns "" when neither resolves.
|
||||||
|
private fun resolveApiKey(config: ProviderConfig): String = when {
|
||||||
|
config.apiKey.isNotBlank() -> config.apiKey
|
||||||
|
config.apiKeyEnv.isNotBlank() -> System.getenv(config.apiKeyEnv).orEmpty()
|
||||||
|
else -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseCapabilitiesFromConfig(capsMap: Map<String, Double>): Set<CapabilityScore> {
|
private fun parseCapabilitiesFromConfig(capsMap: Map<String, Double>): Set<CapabilityScore> {
|
||||||
@@ -740,6 +764,7 @@ private fun buildToolConfig(
|
|||||||
fileRead = FileReadConfig(
|
fileRead = FileReadConfig(
|
||||||
enabled = toolsConfig.fileReadEnabled,
|
enabled = toolsConfig.fileReadEnabled,
|
||||||
allowedPaths = allowed,
|
allowedPaths = allowed,
|
||||||
|
workingDir = workingDir,
|
||||||
),
|
),
|
||||||
fileWrite = FileWriteConfig(
|
fileWrite = FileWriteConfig(
|
||||||
allowedPaths = allowed,
|
allowedPaths = allowed,
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package com.correx.apps.server.metrics
|
||||||
|
|
||||||
|
import com.correx.core.events.events.InferenceCompletedEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||||
|
import com.correx.core.events.stores.EventStore
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
private const val PERCENT = 100.0
|
||||||
|
private const val REASON_BUCKET_MAX = 80
|
||||||
|
private const val TOP_REASONS = 5
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReasonCount(val reason: String, val count: Long)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolReliabilityRow(
|
||||||
|
val toolName: String,
|
||||||
|
val calls: Long,
|
||||||
|
val failures: Long,
|
||||||
|
val validityPct: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProviderToolReliability(
|
||||||
|
val provider: String,
|
||||||
|
val calls: Long,
|
||||||
|
val failures: Long,
|
||||||
|
val validityPct: Double,
|
||||||
|
val byTool: List<ToolReliabilityRow>,
|
||||||
|
val topFailureReasons: List<ReasonCount>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolReliabilityReport(val providers: List<ProviderToolReliability>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-model tool-call reliability, derived by replaying the whole event log: for each provider, how
|
||||||
|
* often did the tool calls it produced actually execute vs. fail (a failed `ToolExecutionFailedEvent`
|
||||||
|
* is the deterministic signal of a malformed/invalid call — wrong args, missing required field, …).
|
||||||
|
*
|
||||||
|
* Tool events don't carry a providerId, so each tool outcome is attributed to the provider of the
|
||||||
|
* most recent `InferenceCompletedEvent` in the same session (one model drives a session's inference
|
||||||
|
* under the default routing). This is the measurement groundwork for capability-aware routing —
|
||||||
|
* steering tool-heavy stages to models with a high observed validity rate.
|
||||||
|
*/
|
||||||
|
class ToolReliabilityInspectionService(private val eventStore: EventStore) {
|
||||||
|
|
||||||
|
private class Agg {
|
||||||
|
var calls: Long = 0
|
||||||
|
var failures: Long = 0
|
||||||
|
val reasons: MutableMap<String, Long> = linkedMapOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun inspect(): ToolReliabilityReport {
|
||||||
|
val sessionProvider = mutableMapOf<String, String>()
|
||||||
|
val byProvider = linkedMapOf<String, Agg>()
|
||||||
|
val byProviderTool = linkedMapOf<Pair<String, String>, Agg>()
|
||||||
|
|
||||||
|
fun record(provider: String, tool: String, failureReason: String?) {
|
||||||
|
val p = byProvider.getOrPut(provider) { Agg() }
|
||||||
|
val pt = byProviderTool.getOrPut(provider to tool) { Agg() }
|
||||||
|
p.calls++; pt.calls++
|
||||||
|
if (failureReason != null) {
|
||||||
|
p.failures++; pt.failures++
|
||||||
|
val bucket = failureReason.lineSequence().firstOrNull().orEmpty().take(REASON_BUCKET_MAX)
|
||||||
|
p.reasons[bucket] = (p.reasons[bucket] ?: 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventStore.allEvents().forEach { stored ->
|
||||||
|
when (val payload = stored.payload) {
|
||||||
|
is InferenceCompletedEvent -> sessionProvider[payload.sessionId.value] = payload.providerId.value
|
||||||
|
is ToolExecutionCompletedEvent ->
|
||||||
|
record(sessionProvider[payload.sessionId.value] ?: "unknown", payload.toolName, null)
|
||||||
|
is ToolExecutionFailedEvent ->
|
||||||
|
record(sessionProvider[payload.sessionId.value] ?: "unknown", payload.toolName, payload.reason)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val providers = byProvider.entries
|
||||||
|
.sortedByDescending { it.value.calls }
|
||||||
|
.map { (provider, agg) ->
|
||||||
|
ProviderToolReliability(
|
||||||
|
provider = provider,
|
||||||
|
calls = agg.calls,
|
||||||
|
failures = agg.failures,
|
||||||
|
validityPct = validity(agg.calls, agg.failures),
|
||||||
|
byTool = byProviderTool.entries
|
||||||
|
.filter { it.key.first == provider }
|
||||||
|
.sortedByDescending { it.value.calls }
|
||||||
|
.map { (key, a) ->
|
||||||
|
ToolReliabilityRow(key.second, a.calls, a.failures, validity(a.calls, a.failures))
|
||||||
|
},
|
||||||
|
topFailureReasons = agg.reasons.entries
|
||||||
|
.sortedByDescending { it.value }
|
||||||
|
.take(TOP_REASONS)
|
||||||
|
.map { ReasonCount(it.key, it.value) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ToolReliabilityReport(providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validity(calls: Long, failures: Long): Double =
|
||||||
|
if (calls == 0L) PERCENT else (calls - failures).toDouble() / calls * PERCENT
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import com.correx.apps.server.replay.ReplayInspectionService
|
|||||||
import com.correx.apps.server.serialization.payloadDiscriminator
|
import com.correx.apps.server.serialization.payloadDiscriminator
|
||||||
import com.correx.apps.server.ws.SessionStreamHandler
|
import com.correx.apps.server.ws.SessionStreamHandler
|
||||||
import com.correx.core.events.events.EventPayload
|
import com.correx.core.events.events.EventPayload
|
||||||
|
import com.correx.core.events.events.InitialIntentEvent
|
||||||
import com.correx.core.events.serialization.eventJson
|
import com.correx.core.events.serialization.eventJson
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.utils.TypeId
|
import com.correx.core.utils.TypeId
|
||||||
@@ -39,7 +40,13 @@ data class EventRow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class StartSessionRequest(val workflowId: String, val config: SessionConfigDto? = null)
|
data class StartSessionRequest(
|
||||||
|
val workflowId: String,
|
||||||
|
val config: SessionConfigDto? = null,
|
||||||
|
// Optional freeform brief for intent-driven workflows (REST parity with the WS StartSession).
|
||||||
|
// Seeded as an InitialIntentEvent before the run so it lands in the decision journal.
|
||||||
|
val intent: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SessionSummaryResponse(
|
data class SessionSummaryResponse(
|
||||||
@@ -108,6 +115,9 @@ private fun Route.startSessionRoute(module: ServerModule) {
|
|||||||
val graph = module.workflowRegistry.find(body.workflowId)
|
val graph = module.workflowRegistry.find(body.workflowId)
|
||||||
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}")
|
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}")
|
||||||
val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
|
val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
|
||||||
|
body.intent?.takeIf { it.isNotBlank() }?.let { intent ->
|
||||||
|
EventDispatcher(module.eventStore).emit(InitialIntentEvent(sessionId, intent), sessionId)
|
||||||
|
}
|
||||||
module.launchSessionRun(sessionId, graph)
|
module.launchSessionRun(sessionId, graph)
|
||||||
call.respond(HttpStatusCode.Accepted, StartSessionResponse(sessionId.value))
|
call.respond(HttpStatusCode.Accepted, StartSessionResponse(sessionId.value))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,6 +537,8 @@ object ConfigLoader {
|
|||||||
modelPath = modelPath,
|
modelPath = modelPath,
|
||||||
url = url,
|
url = url,
|
||||||
capabilities = capabilities,
|
capabilities = capabilities,
|
||||||
|
apiKey = asString(providerMap["api_key"], ""),
|
||||||
|
apiKeyEnv = asString(providerMap["api_key_env"], ""),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ data class ProviderConfig(
|
|||||||
val modelPath: String = "",
|
val modelPath: String = "",
|
||||||
val url: String = "http://127.0.0.1:10000",
|
val url: String = "http://127.0.0.1:10000",
|
||||||
val capabilities: Map<String, Double> = emptyMap(),
|
val capabilities: Map<String, Double> = emptyMap(),
|
||||||
|
// Bearer credentials for remote providers (e.g. NVIDIA NIM). `apiKey` is the literal token;
|
||||||
|
// `apiKeyEnv` names an env var to read it from at startup (preferred — keeps the secret out
|
||||||
|
// of config files). Both are blank/unused for local providers like llamacpp.
|
||||||
|
val apiKey: String = "",
|
||||||
|
val apiKeyEnv: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ object CorrexConfigWriter {
|
|||||||
b.kv("model_path", str(p.modelPath))
|
b.kv("model_path", str(p.modelPath))
|
||||||
b.kv("url", str(p.url))
|
b.kv("url", str(p.url))
|
||||||
if (p.capabilities.isNotEmpty()) b.kv("capabilities", caps(p.capabilities))
|
if (p.capabilities.isNotEmpty()) b.kv("capabilities", caps(p.capabilities))
|
||||||
|
if (p.apiKey.isNotEmpty()) b.kv("api_key", str(p.apiKey))
|
||||||
|
if (p.apiKeyEnv.isNotEmpty()) b.kv("api_key_env", str(p.apiKeyEnv))
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.models.forEach { m ->
|
cfg.models.forEach { m ->
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ CORREX kernel team. This is the integration point for all other `core/` modules.
|
|||||||
- `SessionOrchestrator` / `DefaultSessionOrchestrator` — primary entry point for launching and advancing sessions through workflow stages.
|
- `SessionOrchestrator` / `DefaultSessionOrchestrator` — primary entry point for launching and advancing sessions through workflow stages.
|
||||||
- `OrchestrationState` / `OrchestrationReducer` (`DefaultOrchestrationReducer`) / `OrchestrationProjector` / `OrchestrationRepository` — standard event-sourcing stack for orchestration state.
|
- `OrchestrationState` / `OrchestrationReducer` (`DefaultOrchestrationReducer`) / `OrchestrationProjector` / `OrchestrationRepository` — standard event-sourcing stack for orchestration state.
|
||||||
- `RetryCoordinator` / `DefaultRetryCoordinator` — manages retry logic per `RetryPolicy`.
|
- `RetryCoordinator` / `DefaultRetryCoordinator` — manages retry logic per `RetryPolicy`.
|
||||||
- `ApprovalGateway` — kernel-side approval bridge; calls `core:approvals` engine before executing gated operations.
|
- On a recoverable tool failure `dispatchToolCalls` feeds the failing tool's argument schema back into context alongside the error (`toolArgsHint`), so the model self-corrects a malformed call instead of repeating it — the contract stays strict; the feedback is what loosens.
|
||||||
|
- Stages that grant tools (`allowedTools` non-empty) request `ModelCapability.ToolCalling` on top of their declared capabilities when routing, so the capability-aware strategy steers them to the best tool-calling model.
|
||||||
|
- `ApprovalGateway` — kernel-side approval bridge; calls `core:approvals` engine before executing gated operations. Per-tool gating in `dispatchToolCalls` builds the `ApprovalContext` mode from the session's bound operator profile (`boundProfile.approvalMode`, mapped by `approvalModeFor`): unset/`prompt` keeps a human in the loop (default), `auto` auto-approves up to T2, `yolo` all tiers, `deny` blocks above T0. The engine is always consulted (Invariant #4 holds); policy/plane-2 BLOCK stays terminal regardless of mode.
|
||||||
- `ReplayOrchestrator` / `ReplayInferenceProvider` / `ReplayStrategy` — deterministic replay of a session from its event log. `ReplayInferenceProvider` returns recorded responses — no live LLM (Hard Invariant #8).
|
- `ReplayOrchestrator` / `ReplayInferenceProvider` / `ReplayStrategy` — deterministic replay of a session from its event log. `ReplayInferenceProvider` returns recorded responses — no live LLM (Hard Invariant #8).
|
||||||
- `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
|
- `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
|
||||||
- `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events.
|
- `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events.
|
||||||
|
|||||||
+32
-4
@@ -106,6 +106,7 @@ import com.correx.core.inference.ResponseFormat
|
|||||||
import com.correx.core.inference.Tokenizer
|
import com.correx.core.inference.Tokenizer
|
||||||
import com.correx.core.inference.ToolCallRequest
|
import com.correx.core.inference.ToolCallRequest
|
||||||
import com.correx.core.inference.ToolDefinition
|
import com.correx.core.inference.ToolDefinition
|
||||||
|
import com.correx.core.inference.ModelCapability
|
||||||
import com.correx.core.sessions.ApprovalMode
|
import com.correx.core.sessions.ApprovalMode
|
||||||
import com.correx.core.inference.ToolFunction
|
import com.correx.core.inference.ToolFunction
|
||||||
import com.correx.core.kernel.execution.WorkflowResult
|
import com.correx.core.kernel.execution.WorkflowResult
|
||||||
@@ -561,7 +562,8 @@ abstract class SessionOrchestrator(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val toolEntries = dispatchToolCalls(sessionId, stageId,
|
val toolEntries = dispatchToolCalls(sessionId, stageId,
|
||||||
inferenceResult.response.toolCalls, stageConfig, effectives)
|
inferenceResult.response.toolCalls, stageConfig, effectives,
|
||||||
|
approvalModeFor(session.state.boundProfile?.approvalMode))
|
||||||
val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
|
val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") }
|
||||||
if (fatalEntry != null) {
|
if (fatalEntry != null) {
|
||||||
emitProcessResultEvents(sessionId, stageId, stageConfig)
|
emitProcessResultEvents(sessionId, stageId, stageConfig)
|
||||||
@@ -654,12 +656,33 @@ abstract class SessionOrchestrator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("CyclomaticComplexMethod")
|
@Suppress("CyclomaticComplexMethod")
|
||||||
|
// Maps the operator profile's free-text approval_mode onto the engine's [ApprovalMode].
|
||||||
|
// Unset/unknown falls back to PROMPT so an absent profile keeps the human-in-the-loop
|
||||||
|
// default; only an explicit auto/yolo opts a session into unattended approval.
|
||||||
|
private fun approvalModeFor(profileMode: String?): ApprovalMode =
|
||||||
|
when (profileMode?.trim()?.lowercase()) {
|
||||||
|
"deny" -> ApprovalMode.DENY
|
||||||
|
"auto" -> ApprovalMode.AUTO
|
||||||
|
"yolo" -> ApprovalMode.YOLO
|
||||||
|
else -> ApprovalMode.PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a recoverable tool failure, append the tool's argument schema to the error fed back to the
|
||||||
|
// model so it can self-correct its next attempt (it already sees its own malformed call in the
|
||||||
|
// assistant entry above) rather than repeating the same mistake. Kept compact to bound context.
|
||||||
|
private fun toolArgsHint(tool: Tool?): String =
|
||||||
|
tool?.let {
|
||||||
|
"\nThe '${it.name}' tool requires arguments matching this JSON schema — re-issue the " +
|
||||||
|
"call with corrected arguments: ${it.parametersSchema}"
|
||||||
|
}.orEmpty()
|
||||||
|
|
||||||
private suspend fun dispatchToolCalls(
|
private suspend fun dispatchToolCalls(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
toolCalls: List<ToolCallRequest>,
|
toolCalls: List<ToolCallRequest>,
|
||||||
stageConfig: StageConfig,
|
stageConfig: StageConfig,
|
||||||
effectives: RunEffectives,
|
effectives: RunEffectives,
|
||||||
|
approvalMode: ApprovalMode,
|
||||||
): List<ContextEntry> {
|
): List<ContextEntry> {
|
||||||
val executor = effectives.executor ?: return emptyList()
|
val executor = effectives.executor ?: return emptyList()
|
||||||
val processResultSlots = stageConfig.produces.filter { it.kind.id == "process_result" }
|
val processResultSlots = stageConfig.produces.filter { it.kind.id == "process_result" }
|
||||||
@@ -793,7 +816,7 @@ abstract class SessionOrchestrator(
|
|||||||
val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) }
|
val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) }
|
||||||
val approvalCtx = ApprovalContext(
|
val approvalCtx = ApprovalContext(
|
||||||
identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId),
|
identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId),
|
||||||
mode = ApprovalMode.PROMPT,
|
mode = approvalMode,
|
||||||
)
|
)
|
||||||
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
|
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
|
||||||
val toolPreview = computeToolPreview(toolCall.function.name, parameters)
|
val toolPreview = computeToolPreview(toolCall.function.name, parameters)
|
||||||
@@ -968,7 +991,7 @@ abstract class SessionOrchestrator(
|
|||||||
?: result.output
|
?: result.output
|
||||||
is ToolResult.Failure -> {
|
is ToolResult.Failure -> {
|
||||||
if (!result.recoverable) "FATAL: ${result.reason}"
|
if (!result.recoverable) "FATAL: ${result.reason}"
|
||||||
else "ERROR: ${result.reason}"
|
else "ERROR: ${result.reason}${toolArgsHint(tool)}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val resultEntry = ContextEntry(
|
val resultEntry = ContextEntry(
|
||||||
@@ -1772,7 +1795,12 @@ abstract class SessionOrchestrator(
|
|||||||
responseFormat: ResponseFormat = ResponseFormat.Text,
|
responseFormat: ResponseFormat = ResponseFormat.Text,
|
||||||
effectives: RunEffectives = RunEffectives(toolRegistry, toolExecutor, workspacePolicy),
|
effectives: RunEffectives = RunEffectives(toolRegistry, toolExecutor, workspacePolicy),
|
||||||
): InferenceResult {
|
): InferenceResult {
|
||||||
val provider = inferenceRouter.route(stageId, stageConfig.requiredCapabilities, stageConfig.modelId)
|
// A stage that grants tools needs a tool-calling model, so request ToolCalling on top of any
|
||||||
|
// declared capabilities — the capability-aware strategy then ranks eligible providers by their
|
||||||
|
// ToolCalling score and routes the stage to the best tool-caller.
|
||||||
|
val requiredCapabilities = stageConfig.requiredCapabilities +
|
||||||
|
if (stageConfig.allowedTools.isNotEmpty()) setOf(ModelCapability.ToolCalling) else emptySet()
|
||||||
|
val provider = inferenceRouter.route(stageId, requiredCapabilities, stageConfig.modelId)
|
||||||
log.debug(
|
log.debug(
|
||||||
"[Orchestrator] inference session={} stage={} provider={} timeoutMs={}",
|
"[Orchestrator] inference session={} stage={} provider={} timeoutMs={}",
|
||||||
sessionId.value, stageId.value, provider.id.value, timeoutMs,
|
sessionId.value, stageId.value, provider.id.value, timeoutMs,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ dependencies {
|
|||||||
implementation project(":infrastructure:inference")
|
implementation project(":infrastructure:inference")
|
||||||
implementation project(":infrastructure:inference:commons")
|
implementation project(":infrastructure:inference:commons")
|
||||||
implementation project(":infrastructure:inference:llama_cpp")
|
implementation project(":infrastructure:inference:llama_cpp")
|
||||||
|
implementation project(":infrastructure:inference:openai_compat")
|
||||||
implementation project(":infrastructure:persistence")
|
implementation project(":infrastructure:persistence")
|
||||||
implementation project(":infrastructure:router:turbovec")
|
implementation project(":infrastructure:router:turbovec")
|
||||||
implementation project(":infrastructure:tools")
|
implementation project(":infrastructure:tools")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Inference adapter layer. The root module provides `DefaultProviderRegistry` (registers `InferenceProvider` instances) and `FirstAvailableRoutingStrategy`. Submodules cover shared HTTP client infrastructure (`commons/`) and the llama.cpp server adapter (`llama_cpp/`).
|
Inference adapter layer. The root module provides `DefaultProviderRegistry` (registers `InferenceProvider` instances) and `FirstAvailableRoutingStrategy`. Submodules cover shared HTTP client infrastructure (`commons/`), the llama.cpp server adapter (`llama_cpp/`), and the remote OpenAI-compatible adapter (`openai_compat/`, e.g. NVIDIA NIM).
|
||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
|
|
||||||
@@ -11,10 +11,11 @@ Adapter for LLM inference backends. Implements `core:inference` interfaces. No d
|
|||||||
## Local Contracts
|
## Local Contracts
|
||||||
|
|
||||||
- `DefaultProviderRegistry` implements `ProviderRegistry` from `core:inference`.
|
- `DefaultProviderRegistry` implements `ProviderRegistry` from `core:inference`.
|
||||||
- `FirstAvailableRoutingStrategy` is the default routing policy; extend only in `core:inference`, not here.
|
- Two `RoutingStrategy` implementations (both hard-filter to providers declaring every required capability): `FirstAvailableRoutingStrategy` picks the first match; `CapabilityAwareRoutingStrategy` ranks the matches by summed required-capability score (ties keep list order, so it's a superset of first-available). The server wires `CapabilityAwareRoutingStrategy` so tool-heavy stages (which request `ModelCapability.ToolCalling`) route to the best tool-caller. Extend the `RoutingStrategy` interface only in `core:inference`, not here.
|
||||||
- All LLM responses are proposals — they must be validated by the core before affecting state (invariant #7). Adapters return raw responses; they do not validate.
|
- All LLM responses are proposals — they must be validated by the core before affecting state (invariant #7). Adapters return raw responses; they do not validate.
|
||||||
- Network calls to the llama.cpp server are environment observations; results must be recorded as events by callers if replay must reproduce them (invariant #9).
|
- Network calls to inference backends are environment observations; results must be recorded as events by callers if replay must reproduce them (invariant #9).
|
||||||
- Model lifecycle (spawn/own the llama-server process) lives in `llama_cpp/`; the autonomous scheduler is intentionally unbuilt (see root CLAUDE.md).
|
- Model lifecycle (spawn/own the llama-server process) lives in `llama_cpp/`; the autonomous scheduler is intentionally unbuilt (see root CLAUDE.md).
|
||||||
|
- `openai_compat/` is fully remote (no local process/GPU). It speaks `POST {baseUrl}/chat/completions` with `Authorization: Bearer`; `baseUrl` must include the version segment (e.g. `/v1`). It has no `/tokenize`, so it uses a heuristic tokenizer, and no GBNF — JSON artifacts rely on the core's validate-after-retry. Server dispatch keys `[[providers]] type = "nim" | "openai"`; the key comes from `api_key` or `api_key_env`.
|
||||||
|
|
||||||
## Work Guidance
|
## Work Guidance
|
||||||
|
|
||||||
@@ -26,9 +27,11 @@ Standard adapter rules apply (see parent `AGENTS.md`). HTTP client code uses Kto
|
|||||||
./gradlew :infrastructure:inference:test --rerun-tasks
|
./gradlew :infrastructure:inference:test --rerun-tasks
|
||||||
./gradlew :infrastructure:inference:commons:test --rerun-tasks
|
./gradlew :infrastructure:inference:commons:test --rerun-tasks
|
||||||
./gradlew :infrastructure:inference:llama_cpp:test --rerun-tasks
|
./gradlew :infrastructure:inference:llama_cpp:test --rerun-tasks
|
||||||
|
./gradlew :infrastructure:inference:openai_compat:test --rerun-tasks
|
||||||
```
|
```
|
||||||
|
|
||||||
## Child DOX Index
|
## Child DOX Index
|
||||||
|
|
||||||
- `commons/` — shared `ManagedInferenceProvider`, `ModelManager`, `ResourceProbe` (Nvidia/AMD), `ResidencyMode`; no separate AGENTS.md (sub-leaf, covered by this doc)
|
- `commons/` — shared `ManagedInferenceProvider`, `ModelManager`, `ResourceProbe` (Nvidia/AMD), `ResidencyMode`; no separate AGENTS.md (sub-leaf, covered by this doc)
|
||||||
- `llama_cpp/` — `LlamaCppInferenceProvider`, `LlamaProcess` (spawns/owns llama-server), `LlamaCppEmbedder`, `LlamaCppTokenizer`, `GbnfGrammarConverter`; no separate AGENTS.md (sub-leaf, covered by this doc)
|
- `llama_cpp/` — `LlamaCppInferenceProvider`, `LlamaProcess` (spawns/owns llama-server), `LlamaCppEmbedder`, `LlamaCppTokenizer`, `GbnfGrammarConverter`; no separate AGENTS.md (sub-leaf, covered by this doc)
|
||||||
|
- `openai_compat/` — `OpenAiCompatInferenceProvider` (remote Bearer-auth chat completions for NVIDIA NIM/OpenAI), `HeuristicTokenizer`, `OpenAiApiModels`; no separate AGENTS.md (sub-leaf, covered by this doc)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java-library'
|
||||||
|
id 'org.jetbrains.kotlin.jvm'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
|
}
|
||||||
|
|
||||||
|
ext {
|
||||||
|
ktor_version = '3.0.3'
|
||||||
|
ext.ktor_version = '3.0.3'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':core:inference')
|
||||||
|
implementation project(':core:events')
|
||||||
|
implementation project(':core:artifacts')
|
||||||
|
implementation project(':core:context')
|
||||||
|
implementation project(':infrastructure:inference:commons')
|
||||||
|
|
||||||
|
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||||
|
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||||
|
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
|
||||||
|
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
|
||||||
|
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||||
|
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||||
|
testImplementation "io.ktor:ktor-client-mock:$ktor_version"
|
||||||
|
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("koverVerify").configure { enabled = false }
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.correx.infrastructure.inference.openai
|
||||||
|
|
||||||
|
import com.correx.core.inference.Token
|
||||||
|
import com.correx.core.inference.Tokenizer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote OpenAI-compatible backends (NIM, OpenAI) expose no `/tokenize` endpoint, so we can't
|
||||||
|
* get exact token ids without shipping a tokenizer. This is a length-based approximation
|
||||||
|
* (~4 chars/token) used only for context-budget estimates; the synthesized [Token] ids are
|
||||||
|
* placeholders and must not be treated as real model tokens.
|
||||||
|
*/
|
||||||
|
class HeuristicTokenizer(private val charsPerToken: Int = 4) : Tokenizer {
|
||||||
|
|
||||||
|
override suspend fun tokenize(text: String): List<Token> {
|
||||||
|
val count = countTokens(text)
|
||||||
|
return List(count) { Token(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun countTokens(text: String): Int =
|
||||||
|
if (text.isEmpty()) 0 else (text.length + charsPerToken - 1) / charsPerToken
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.correx.infrastructure.inference.openai
|
||||||
|
|
||||||
|
import com.correx.core.inference.ToolCallRequest
|
||||||
|
import com.correx.core.inference.ToolDefinition
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire models for the OpenAI-compatible `/chat/completions` endpoint (NVIDIA NIM, OpenAI,
|
||||||
|
* vLLM, etc.). Unlike the llama.cpp models there is no `grammar` field — structured output
|
||||||
|
* on these backends is `response_format`/tools, not GBNF. JSON artifacts rely on the
|
||||||
|
* orchestrator's validate-after-retry path (invariant #7).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class OpenAiChatCompletionRequest(
|
||||||
|
val model: String,
|
||||||
|
val messages: List<OpenAiChatMessage>,
|
||||||
|
val temperature: Double,
|
||||||
|
@SerialName("top_p") val topP: Double,
|
||||||
|
@SerialName("max_tokens") val maxTokens: Int,
|
||||||
|
@SerialName("stop") val stopSequences: List<String>? = null,
|
||||||
|
val seed: Long? = null,
|
||||||
|
val stream: Boolean = false,
|
||||||
|
val tools: List<ToolDefinition>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OpenAiChatMessage(
|
||||||
|
val role: String,
|
||||||
|
val content: String? = null,
|
||||||
|
@SerialName("tool_calls") val toolCalls: List<ToolCallRequest> = emptyList(),
|
||||||
|
@SerialName("tool_call_id") val toolCallId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OpenAiChatCompletionResponse(
|
||||||
|
val id: String? = null,
|
||||||
|
val choices: List<OpenAiChoice> = emptyList(),
|
||||||
|
val usage: OpenAiUsage? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OpenAiChoice(
|
||||||
|
val message: OpenAiChatMessage,
|
||||||
|
@SerialName("finish_reason") val finishReason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OpenAiUsage(
|
||||||
|
@SerialName("prompt_tokens") val promptTokens: Int = 0,
|
||||||
|
@SerialName("completion_tokens") val completionTokens: Int = 0,
|
||||||
|
@SerialName("total_tokens") val totalTokens: Int = 0,
|
||||||
|
)
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package com.correx.infrastructure.inference.openai
|
||||||
|
|
||||||
|
import com.correx.core.events.types.ProviderId
|
||||||
|
import com.correx.core.inference.CapabilityScore
|
||||||
|
import com.correx.core.inference.FinishReason
|
||||||
|
import com.correx.core.inference.InferenceProvider
|
||||||
|
import com.correx.core.inference.InferenceRequest
|
||||||
|
import com.correx.core.inference.InferenceResponse
|
||||||
|
import com.correx.core.inference.PromptRenderer
|
||||||
|
import com.correx.core.inference.ProviderHealth
|
||||||
|
import com.correx.core.inference.TokenUsage
|
||||||
|
import com.correx.core.inference.Tokenizer
|
||||||
|
import com.correx.core.inference.ToolCallRequest
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.accept
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
|
private const val DEFAULT_REQUEST_TIMEOUT_MS = 600_000L
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) { json(json) }
|
||||||
|
install(HttpTimeout) { requestTimeoutMillis = DEFAULT_REQUEST_TIMEOUT_MS }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(OpenAiCompatInferenceProvider::class.java)
|
||||||
|
|
||||||
|
// Some models emit a tool call as a raw JSON blob in `content` instead of the native
|
||||||
|
// tool_calls array; salvage it so the orchestrator gets a real call rather than treating
|
||||||
|
// the blob as a (failing) artifact. Mirrors the llama.cpp provider's recovery path.
|
||||||
|
internal fun salvageToolCalls(content: String?): List<ToolCallRequest> {
|
||||||
|
val s = content?.trim().orEmpty()
|
||||||
|
if (s.isEmpty() || (s.first() != '{' && s.first() != '[')) return emptyList()
|
||||||
|
return runCatching { json.decodeFromString<List<ToolCallRequest>>(s) }
|
||||||
|
.recoverCatching { listOf(json.decodeFromString<ToolCallRequest>(s)) }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
.filter { it.function.name.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [InferenceProvider] for OpenAI-compatible chat APIs reached over HTTPS with a Bearer key —
|
||||||
|
* NVIDIA NIM (`https://integrate.api.nvidia.com/v1`), OpenAI, vLLM, etc. Inference is fully
|
||||||
|
* remote: no local process, no GPU, no GGUF. [baseUrl] must already include the API version
|
||||||
|
* segment (e.g. `.../v1`); this class appends `/chat/completions` and `/models`.
|
||||||
|
*
|
||||||
|
* @param idPrefix label for the [ProviderId] (e.g. `nim`, `openai`).
|
||||||
|
* @param apiKey Bearer token; sent as `Authorization: Bearer <key>` when non-blank.
|
||||||
|
*/
|
||||||
|
@Suppress("TooGenericExceptionCaught", "MagicNumber")
|
||||||
|
class OpenAiCompatInferenceProvider(
|
||||||
|
private val modelId: String,
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val apiKey: String,
|
||||||
|
private val capabilities: Set<CapabilityScore>,
|
||||||
|
private val idPrefix: String = "openai",
|
||||||
|
private val httpClient: HttpClient = defaultHttpClient(),
|
||||||
|
) : InferenceProvider {
|
||||||
|
|
||||||
|
override val id: ProviderId = ProviderId("$idPrefix:$modelId")
|
||||||
|
override val name: String = "OpenAI-compatible ($idPrefix:$modelId)"
|
||||||
|
override val tokenizer: Tokenizer = HeuristicTokenizer()
|
||||||
|
|
||||||
|
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val messages = PromptRenderer.render(request.contextPack).map { msg ->
|
||||||
|
// Strict OpenAI-compatible servers reject a `tool` role that isn't bound to a prior
|
||||||
|
// tool_calls turn (we don't carry tool_call_ids through the context pack). Fold any
|
||||||
|
// non-standard role into a user turn so the model still sees the content.
|
||||||
|
if (msg.role in STANDARD_ROLES) {
|
||||||
|
OpenAiChatMessage(role = msg.role, content = msg.content)
|
||||||
|
} else {
|
||||||
|
OpenAiChatMessage(role = "user", content = "[${msg.role}] ${msg.content}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val tools = request.tools.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
val body = OpenAiChatCompletionRequest(
|
||||||
|
model = modelId,
|
||||||
|
messages = messages,
|
||||||
|
temperature = request.generationConfig.temperature,
|
||||||
|
topP = request.generationConfig.topP,
|
||||||
|
maxTokens = request.generationConfig.maxTokens,
|
||||||
|
stopSequences = request.generationConfig.stopSequences.ifEmpty { null },
|
||||||
|
seed = request.generationConfig.seed,
|
||||||
|
stream = false,
|
||||||
|
tools = tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
val encoded = json.encodeToString(body)
|
||||||
|
log.debug("sending request to {}: {}", id.value, encoded)
|
||||||
|
|
||||||
|
val httpResponse = httpClient.post("$baseUrl/chat/completions") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
accept(ContentType.Application.Json)
|
||||||
|
if (apiKey.isNotBlank()) header(HttpHeaders.Authorization, "Bearer $apiKey")
|
||||||
|
setBody(encoded)
|
||||||
|
}
|
||||||
|
if (httpResponse.status.value !in 200..299) {
|
||||||
|
val errorBody = httpResponse.bodyAsText()
|
||||||
|
error("$idPrefix returned ${httpResponse.status.value} ${httpResponse.status.description}: $errorBody")
|
||||||
|
}
|
||||||
|
val response = httpResponse.body<OpenAiChatCompletionResponse>()
|
||||||
|
log.debug("got response from {}: {}", id.value, response)
|
||||||
|
|
||||||
|
val choice = response.choices.firstOrNull()
|
||||||
|
?: error("$idPrefix returned no choices")
|
||||||
|
val message = choice.message
|
||||||
|
val salvaged = if (message.toolCalls.isEmpty()) salvageToolCalls(message.content) else emptyList()
|
||||||
|
val toolCalls = message.toolCalls.ifEmpty { salvaged }
|
||||||
|
val finishReason = when {
|
||||||
|
toolCalls.isNotEmpty() -> FinishReason.ToolCall
|
||||||
|
choice.finishReason?.lowercase() == "length" -> FinishReason.Length
|
||||||
|
else -> FinishReason.Stop
|
||||||
|
}
|
||||||
|
|
||||||
|
return InferenceResponse(
|
||||||
|
requestId = request.requestId,
|
||||||
|
text = if (salvaged.isNotEmpty()) "" else message.content ?: "",
|
||||||
|
finishReason = finishReason,
|
||||||
|
tokensUsed = TokenUsage(
|
||||||
|
promptTokens = response.usage?.promptTokens ?: 0,
|
||||||
|
completionTokens = response.usage?.completionTokens ?: 0,
|
||||||
|
),
|
||||||
|
latencyMs = System.currentTimeMillis() - startTime,
|
||||||
|
toolCalls = toolCalls,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun healthCheck(): ProviderHealth = try {
|
||||||
|
val response = httpClient.get("$baseUrl/models") {
|
||||||
|
if (apiKey.isNotBlank()) header(HttpHeaders.Authorization, "Bearer $apiKey")
|
||||||
|
}
|
||||||
|
if (response.status.value in 200..299) {
|
||||||
|
ProviderHealth.Healthy
|
||||||
|
} else {
|
||||||
|
ProviderHealth.Unavailable("Health check returned non-2xx status: ${response.status}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ProviderHealth.Unavailable("Health check failed: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun capabilities(): Set<CapabilityScore> = capabilities
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private val STANDARD_ROLES = setOf("system", "user", "assistant")
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
package com.correx.infrastructure.inference.openai
|
||||||
|
|
||||||
|
import com.correx.core.context.model.ContextPack
|
||||||
|
import com.correx.core.events.types.ContextPackId
|
||||||
|
import com.correx.core.events.types.InferenceRequestId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.inference.CapabilityScore
|
||||||
|
import com.correx.core.inference.FinishReason
|
||||||
|
import com.correx.core.inference.GenerationConfig
|
||||||
|
import com.correx.core.inference.InferenceRequest
|
||||||
|
import com.correx.core.inference.ModelCapability
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.mock.MockEngine
|
||||||
|
import io.ktor.client.engine.mock.respond
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.HttpRequestData
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.headersOf
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class OpenAiCompatInferenceProviderTest {
|
||||||
|
|
||||||
|
private fun request() = InferenceRequest(
|
||||||
|
requestId = InferenceRequestId("req1"),
|
||||||
|
sessionId = SessionId("s1"),
|
||||||
|
stageId = StageId("stage1"),
|
||||||
|
contextPack = ContextPack(
|
||||||
|
id = ContextPackId("cp1"),
|
||||||
|
sessionId = SessionId("s1"),
|
||||||
|
stageId = StageId("stage1"),
|
||||||
|
layers = emptyMap(),
|
||||||
|
budgetUsed = 0,
|
||||||
|
budgetLimit = 4096,
|
||||||
|
),
|
||||||
|
generationConfig = GenerationConfig(temperature = 0.2, topP = 0.9, maxTokens = 256),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun clientReturning(body: String, capture: (HttpRequestData) -> Unit = {}): HttpClient {
|
||||||
|
val engine = MockEngine { req ->
|
||||||
|
capture(req)
|
||||||
|
respond(
|
||||||
|
content = body,
|
||||||
|
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return HttpClient(engine) {
|
||||||
|
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provider(client: HttpClient) = OpenAiCompatInferenceProvider(
|
||||||
|
modelId = "deepseek-ai/deepseek-v4-flash",
|
||||||
|
baseUrl = "https://integrate.api.nvidia.com/v1",
|
||||||
|
apiKey = "nvapi-test",
|
||||||
|
capabilities = setOf(CapabilityScore(ModelCapability.General, 1.0)),
|
||||||
|
idPrefix = "nim",
|
||||||
|
httpClient = client,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `infer sends bearer auth to chat completions and returns text`(): Unit = runBlocking {
|
||||||
|
var seenAuth: String? = null
|
||||||
|
var seenUrl: String? = null
|
||||||
|
val client = clientReturning(
|
||||||
|
"""
|
||||||
|
{"id":"x","choices":[{"message":{"role":"assistant","content":"hello there"},
|
||||||
|
"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":3,"total_tokens":14}}
|
||||||
|
""".trimIndent(),
|
||||||
|
) { req ->
|
||||||
|
seenAuth = req.headers[HttpHeaders.Authorization]
|
||||||
|
seenUrl = req.url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
val resp = provider(client).infer(request())
|
||||||
|
|
||||||
|
assertEquals("Bearer nvapi-test", seenAuth)
|
||||||
|
assertEquals("https://integrate.api.nvidia.com/v1/chat/completions", seenUrl)
|
||||||
|
assertEquals("hello there", resp.text)
|
||||||
|
assertEquals(FinishReason.Stop, resp.finishReason)
|
||||||
|
assertEquals(11, resp.tokensUsed.promptTokens)
|
||||||
|
assertEquals(3, resp.tokensUsed.completionTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `infer surfaces native tool calls as ToolCall finish`(): Unit = runBlocking {
|
||||||
|
val client = clientReturning(
|
||||||
|
"""
|
||||||
|
{"id":"x","choices":[{"message":{"role":"assistant","content":null,
|
||||||
|
"tool_calls":[{"id":"c1","type":"function","function":{"name":"file_read","arguments":"{}"}}]},
|
||||||
|
"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val resp = provider(client).infer(request())
|
||||||
|
|
||||||
|
assertEquals(FinishReason.ToolCall, resp.finishReason)
|
||||||
|
assertEquals(1, resp.toolCalls.size)
|
||||||
|
assertEquals("file_read", resp.toolCalls.first().function.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `infer salvages a tool call emitted as a JSON blob in content`(): Unit = runBlocking {
|
||||||
|
val client = clientReturning(
|
||||||
|
"""
|
||||||
|
{"id":"x","choices":[{"message":{"role":"assistant",
|
||||||
|
"content":"{\"function\":{\"name\":\"file_read\",\"arguments\":\"{}\"}}"},
|
||||||
|
"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":9,"total_tokens":13}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val resp = provider(client).infer(request())
|
||||||
|
|
||||||
|
assertEquals(FinishReason.ToolCall, resp.finishReason)
|
||||||
|
assertEquals("file_read", resp.toolCalls.first().function.name)
|
||||||
|
assertEquals("", resp.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `salvageToolCalls leaves plain artifact JSON alone`() {
|
||||||
|
assertTrue(salvageToolCalls("""{"title":"a plan","steps":[]}""").isEmpty())
|
||||||
|
assertTrue(salvageToolCalls("not json").isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package com.correx.infrastructure.inference
|
||||||
|
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.inference.InferenceProvider
|
||||||
|
import com.correx.core.inference.ModelCapability
|
||||||
|
import com.correx.core.inference.NoEligibleProviderException
|
||||||
|
import com.correx.core.inference.RoutingStrategy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like [FirstAvailableRoutingStrategy] it hard-filters to providers that declare every
|
||||||
|
* [requiredCapabilities] — but among those it picks the one with the highest summed capability
|
||||||
|
* **score** for the required set (falling back to total declared score when nothing is required).
|
||||||
|
* Ties keep list order, so behaviour is identical to first-available when scores are equal.
|
||||||
|
*
|
||||||
|
* This is the seam the orchestrator relies on to steer tool-heavy stages (which request
|
||||||
|
* `ModelCapability.ToolCalling`) to the model that calls tools best, rather than whichever healthy
|
||||||
|
* provider happens to come first. The scores come from each provider's declared
|
||||||
|
* `capabilities()` (config-supplied); observed per-model reliability (the `/metrics/tool-reliability`
|
||||||
|
* groundwork) can later feed in as an additional weight.
|
||||||
|
*/
|
||||||
|
class CapabilityAwareRoutingStrategy : RoutingStrategy {
|
||||||
|
override fun select(
|
||||||
|
candidates: List<InferenceProvider>,
|
||||||
|
requiredCapabilities: Set<ModelCapability>,
|
||||||
|
): InferenceProvider {
|
||||||
|
val eligible = candidates.filter { provider ->
|
||||||
|
provider.capabilities().map { it.capability }.toSet().containsAll(requiredCapabilities)
|
||||||
|
}
|
||||||
|
// maxByOrNull keeps the first element on ties, preserving first-available order.
|
||||||
|
return eligible.maxByOrNull { score(it, requiredCapabilities) }
|
||||||
|
?: throw NoEligibleProviderException(StageId("routing"), requiredCapabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun score(provider: InferenceProvider, required: Set<ModelCapability>): Double {
|
||||||
|
val scores = provider.capabilities().associate { it.capability to it.score }
|
||||||
|
return if (required.isEmpty()) scores.values.sum() else required.sumOf { scores[it] ?: 0.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package com.correx.infrastructure.inference
|
||||||
|
|
||||||
|
import com.correx.core.events.types.ProviderId
|
||||||
|
import com.correx.core.inference.CapabilityScore
|
||||||
|
import com.correx.core.inference.InferenceProvider
|
||||||
|
import com.correx.core.inference.InferenceRequest
|
||||||
|
import com.correx.core.inference.InferenceResponse
|
||||||
|
import com.correx.core.inference.ModelCapability
|
||||||
|
import com.correx.core.inference.NoEligibleProviderException
|
||||||
|
import com.correx.core.inference.ProviderHealth
|
||||||
|
import com.correx.core.inference.Token
|
||||||
|
import com.correx.core.inference.Tokenizer
|
||||||
|
import com.correx.core.utils.TypeId
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
|
||||||
|
class CapabilityAwareRoutingStrategyTest {
|
||||||
|
|
||||||
|
private val strategy = CapabilityAwareRoutingStrategy()
|
||||||
|
|
||||||
|
private fun fakeProvider(id: String, vararg caps: Pair<ModelCapability, Double>): InferenceProvider =
|
||||||
|
object : InferenceProvider {
|
||||||
|
override val id: ProviderId = TypeId(id)
|
||||||
|
override val name: String = id
|
||||||
|
override val tokenizer: Tokenizer = object : Tokenizer {
|
||||||
|
override suspend fun tokenize(text: String): List<Token> = emptyList()
|
||||||
|
override suspend fun countTokens(text: String): Int = 0
|
||||||
|
}
|
||||||
|
override suspend fun infer(request: InferenceRequest): InferenceResponse = error("unused")
|
||||||
|
override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
|
||||||
|
override fun capabilities(): Set<CapabilityScore> =
|
||||||
|
caps.map { CapabilityScore(it.first, it.second) }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `picks the highest-scoring provider for the required capability`() {
|
||||||
|
val weak = fakeProvider("weak", ModelCapability.ToolCalling to 0.4, ModelCapability.General to 1.0)
|
||||||
|
val strong = fakeProvider("strong", ModelCapability.ToolCalling to 0.95, ModelCapability.General to 1.0)
|
||||||
|
val selected = strategy.select(listOf(weak, strong), setOf(ModelCapability.ToolCalling))
|
||||||
|
assertEquals("strong", selected.id.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `still hard-filters to providers declaring the required capability`() {
|
||||||
|
val noTools = fakeProvider("noTools", ModelCapability.General to 1.0)
|
||||||
|
val tools = fakeProvider("tools", ModelCapability.ToolCalling to 0.3)
|
||||||
|
val selected = strategy.select(listOf(noTools, tools), setOf(ModelCapability.ToolCalling))
|
||||||
|
assertEquals("tools", selected.id.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `throws when no candidate declares the required capability`() {
|
||||||
|
val general = fakeProvider("general", ModelCapability.General to 1.0)
|
||||||
|
assertThrows<NoEligibleProviderException> {
|
||||||
|
strategy.select(listOf(general), setOf(ModelCapability.ToolCalling))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps list order on a score tie (first-available parity)`() {
|
||||||
|
val a = fakeProvider("a", ModelCapability.ToolCalling to 0.5)
|
||||||
|
val b = fakeProvider("b", ModelCapability.ToolCalling to 0.5)
|
||||||
|
assertEquals("a", strategy.select(listOf(a, b), setOf(ModelCapability.ToolCalling)).id.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `with no required capabilities ranks by total declared score`() {
|
||||||
|
val lean = fakeProvider("lean", ModelCapability.General to 0.5)
|
||||||
|
val rich = fakeProvider("rich", ModelCapability.General to 1.0, ModelCapability.Coding to 0.9)
|
||||||
|
assertEquals("rich", strategy.select(listOf(lean, rich), emptySet()).id.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ import com.correx.infrastructure.inference.commons.ResidencyMode
|
|||||||
import com.correx.infrastructure.inference.llama.cpp.DefaultModelManager
|
import com.correx.infrastructure.inference.llama.cpp.DefaultModelManager
|
||||||
import com.correx.infrastructure.inference.llama.cpp.LlamaCppEmbedder
|
import com.correx.infrastructure.inference.llama.cpp.LlamaCppEmbedder
|
||||||
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
||||||
|
import com.correx.infrastructure.inference.openai.OpenAiCompatInferenceProvider
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.cio.CIO
|
||||||
import com.correx.infrastructure.router.turbovec.TurboVecL3MemoryStore
|
import com.correx.infrastructure.router.turbovec.TurboVecL3MemoryStore
|
||||||
@@ -127,6 +128,20 @@ object InfrastructureModule {
|
|||||||
baseUrl = baseUrl,
|
baseUrl = baseUrl,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun createOpenAiCompatProvider(
|
||||||
|
modelId: String,
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
idPrefix: String = "openai",
|
||||||
|
capabilities: Set<CapabilityScore> = DEFAULT_LLAMA_CAPABILITIES,
|
||||||
|
): OpenAiCompatInferenceProvider = OpenAiCompatInferenceProvider(
|
||||||
|
modelId = modelId,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
apiKey = apiKey,
|
||||||
|
idPrefix = idPrefix,
|
||||||
|
capabilities = capabilities,
|
||||||
|
)
|
||||||
|
|
||||||
fun createModelManager(
|
fun createModelManager(
|
||||||
settings: ModelsSettings,
|
settings: ModelsSettings,
|
||||||
eventStore: EventStore,
|
eventStore: EventStore,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Adapter for `core:tools`. Depends on `core:tools`, `core:events`, `core:approval
|
|||||||
- Web search and web fetch results are environment observations; they must be recorded as events by callers to preserve replay determinism (invariant #9).
|
- Web search and web fetch results are environment observations; they must be recorded as events by callers to preserve replay determinism (invariant #9).
|
||||||
- `ToolConfig` is the only configuration surface; pass via `InfrastructureModule.createToolExecutor()`.
|
- `ToolConfig` is the only configuration surface; pass via `InfrastructureModule.createToolExecutor()`.
|
||||||
- `buildTools()` extension on `ToolConfig` assembles the full tool list; add new tools there, not in the registry directly.
|
- `buildTools()` extension on `ToolConfig` assembles the full tool list; add new tools there, not in the registry directly.
|
||||||
|
- Filesystem mutation is split by intent: `file_write` only writes (`{path, content}`), `file_edit` edits, and `file_delete` only deletes (`{path}`) — deletion is a separately-named capability so a model can never delete by getting a write-mode parameter wrong. `file_delete` shares `file_write`'s path jail and `fileWrite.enabled` toggle and carries `ToolCapability.FILE_WRITE`.
|
||||||
|
|
||||||
## Work Guidance
|
## Work Guidance
|
||||||
|
|
||||||
@@ -30,4 +31,4 @@ Standard adapter rules apply (see parent `AGENTS.md`). Network calls (web tools)
|
|||||||
|
|
||||||
## Child DOX Index
|
## Child DOX Index
|
||||||
|
|
||||||
- `filesystem/` — filesystem read/write/list tools implementing `core:tools` contracts; no separate AGENTS.md (sub-leaf, covered by this doc)
|
- `filesystem/` — filesystem tools implementing `core:tools` contracts: `FileReadTool`, `FileWriteTool` (write-only), `FileDeleteTool`, `FileEditTool`, list; no separate AGENTS.md (sub-leaf, covered by this doc)
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package com.correx.infrastructure.tools.filesystem
|
||||||
|
|
||||||
|
import com.correx.core.approvals.Tier
|
||||||
|
import com.correx.core.events.events.ToolRequest
|
||||||
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import com.correx.core.tools.contract.FileAffectingTool
|
||||||
|
import com.correx.core.tools.contract.ParamRole
|
||||||
|
import com.correx.core.tools.contract.Tool
|
||||||
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
|
import com.correx.core.tools.contract.ToolExecutor
|
||||||
|
import com.correx.core.tools.contract.ToolResult
|
||||||
|
import com.correx.core.tools.contract.ValidationResult
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonObject
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.InvalidPathException
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a file. Split out of [FileWriteTool] so deletion is an explicitly-named capability a model
|
||||||
|
* must choose deliberately — it can never happen by getting a write-mode parameter wrong. Carries the
|
||||||
|
* [ToolCapability.FILE_WRITE] capability (file mutation) and the same path jail as the writer.
|
||||||
|
*/
|
||||||
|
class FileDeleteTool(
|
||||||
|
allowedPaths: Set<Path> = emptySet(),
|
||||||
|
private val workingDir: Path? = null,
|
||||||
|
) : Tool, FileAffectingTool, ToolExecutor {
|
||||||
|
|
||||||
|
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||||
|
|
||||||
|
override val name: String = "file_delete"
|
||||||
|
override val description: String = "Delete the file at the specified relative path"
|
||||||
|
override val parametersSchema: JsonObject = buildJsonObject {
|
||||||
|
put("type", "object")
|
||||||
|
putJsonObject("properties") {
|
||||||
|
putJsonObject("path") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "Relative path of the file to delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put("required", buildJsonArray { add(JsonPrimitive("path")) })
|
||||||
|
}
|
||||||
|
override val tier: Tier = Tier.T2
|
||||||
|
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
|
||||||
|
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
|
||||||
|
|
||||||
|
override fun affectedPaths(request: ToolRequest): Set<Path> {
|
||||||
|
val pathString = request.parameters["path"] as? String ?: return emptySet()
|
||||||
|
return setOf(resolvePath(pathString))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolvePath(pathString: String): Path {
|
||||||
|
val raw = Paths.get(pathString)
|
||||||
|
return when {
|
||||||
|
raw.isAbsolute -> raw.normalize()
|
||||||
|
workingDir != null -> workingDir.resolve(raw).normalize()
|
||||||
|
else -> raw.toAbsolutePath().normalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||||
|
val pathString = request.parameters["path"] as? String
|
||||||
|
?: return ValidationResult.Invalid(
|
||||||
|
"""Missing 'path' parameter (string). Call file_delete with {"path": "<relative path>"}.""",
|
||||||
|
)
|
||||||
|
return runCatching {
|
||||||
|
val path = resolvePath(pathString)
|
||||||
|
when {
|
||||||
|
normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
|
||||||
|
!PathJail.isContained(path, normalizedAllowedPaths) ->
|
||||||
|
ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
|
||||||
|
|
||||||
|
else -> ValidationResult.Valid
|
||||||
|
}
|
||||||
|
}.getOrElse { e -> mapExceptionToValidationResult(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
|
||||||
|
when (e) {
|
||||||
|
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
|
||||||
|
is IOException -> ValidationResult.Invalid("IO error: ${e.message}")
|
||||||
|
is SecurityException -> ValidationResult.Invalid("Security error: ${e.message}")
|
||||||
|
else -> ValidationResult.Invalid(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
|
||||||
|
val validation = validateRequest(request)
|
||||||
|
if (validation is ValidationResult.Invalid) {
|
||||||
|
return@withContext ToolResult.Failure(
|
||||||
|
invocationId = request.invocationId,
|
||||||
|
reason = validation.reason,
|
||||||
|
recoverable = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val pathString = request.parameters["path"] as String
|
||||||
|
val path = resolvePath(pathString)
|
||||||
|
runCatching {
|
||||||
|
if (Files.deleteIfExists(path)) {
|
||||||
|
ToolResult.Success(
|
||||||
|
invocationId = request.invocationId,
|
||||||
|
output = "File deleted successfully: $pathString",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ToolResult.Failure(
|
||||||
|
invocationId = request.invocationId,
|
||||||
|
reason = "File not found: $pathString",
|
||||||
|
recoverable = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrElse { e -> handleExecutionException(e, request.invocationId, pathString) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleExecutionException(
|
||||||
|
e: Throwable,
|
||||||
|
invocationId: ToolInvocationId,
|
||||||
|
pathString: String,
|
||||||
|
): ToolResult = when (e) {
|
||||||
|
is CancellationException -> throw e
|
||||||
|
is IOException -> ToolResult.Failure(invocationId, "IO error: ${e.message}", recoverable = false)
|
||||||
|
is SecurityException ->
|
||||||
|
ToolResult.Failure(invocationId, "Access denied: $pathString, ${e.message}", recoverable = false)
|
||||||
|
|
||||||
|
else -> ToolResult.Failure(invocationId, e.message ?: "Unknown error occurred", recoverable = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-74
@@ -25,6 +25,12 @@ import java.nio.file.InvalidPathException
|
|||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes content to a file. Write-only by design: deleting is the separate, explicitly-named
|
||||||
|
* [FileDeleteTool] so a model can never delete a file by getting an `operation` mode wrong — a
|
||||||
|
* destructive action must name itself. (Previously this tool carried an `operation: write|delete`
|
||||||
|
* mode; splitting it removes the most-forgotten parameter and makes delete a distinct capability.)
|
||||||
|
*/
|
||||||
class FileWriteTool(
|
class FileWriteTool(
|
||||||
allowedPaths: Set<Path> = emptySet(),
|
allowedPaths: Set<Path> = emptySet(),
|
||||||
private val workingDir: Path? = null,
|
private val workingDir: Path? = null,
|
||||||
@@ -33,7 +39,7 @@ class FileWriteTool(
|
|||||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||||
|
|
||||||
override val name: String = "file_write"
|
override val name: String = "file_write"
|
||||||
override val description: String = "Write content to a file at the specified path or delete the file entirely"
|
override val description: String = "Write content to a file at the specified relative path (creates or overwrites)"
|
||||||
override val parametersSchema: JsonObject = buildJsonObject {
|
override val parametersSchema: JsonObject = buildJsonObject {
|
||||||
put("type", "object")
|
put("type", "object")
|
||||||
putJsonObject("properties") {
|
putJsonObject("properties") {
|
||||||
@@ -43,11 +49,7 @@ class FileWriteTool(
|
|||||||
}
|
}
|
||||||
putJsonObject("content") {
|
putJsonObject("content") {
|
||||||
put("type", "string")
|
put("type", "string")
|
||||||
put("description", "File content")
|
put("description", "The full file content to write")
|
||||||
}
|
|
||||||
putJsonObject("operation") {
|
|
||||||
put("type", "string")
|
|
||||||
put("description", "Either 'write' or 'delete'")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
put(
|
put(
|
||||||
@@ -55,7 +57,6 @@ class FileWriteTool(
|
|||||||
buildJsonArray {
|
buildJsonArray {
|
||||||
add(JsonPrimitive("path"))
|
add(JsonPrimitive("path"))
|
||||||
add(JsonPrimitive("content"))
|
add(JsonPrimitive("content"))
|
||||||
add(JsonPrimitive("operation"))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -78,45 +79,37 @@ class FileWriteTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||||
val operation = request.parameters["operation"] as? String
|
|
||||||
val pathString = request.parameters["path"] as? String
|
val pathString = request.parameters["path"] as? String
|
||||||
|
val hasContent = request.parameters.containsKey("content")
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
operation == null ->
|
|
||||||
ValidationResult.Invalid("Missing 'operation' parameter. Expected 'write' or 'delete'.")
|
|
||||||
|
|
||||||
pathString == null ->
|
pathString == null ->
|
||||||
ValidationResult.Invalid("Missing 'path' parameter. Expected String.")
|
ValidationResult.Invalid(
|
||||||
|
"Missing 'path' parameter (string). Call file_write with " +
|
||||||
|
"""{"path": "<relative path>", "content": "<full file content>"}.""",
|
||||||
|
)
|
||||||
|
|
||||||
|
!hasContent ->
|
||||||
|
ValidationResult.Invalid(
|
||||||
|
"Missing 'content' parameter (string). Call file_write with " +
|
||||||
|
"""{"path": "$pathString", "content": "<full file content>"}.""",
|
||||||
|
)
|
||||||
|
|
||||||
else -> runCatching {
|
else -> runCatching {
|
||||||
val path = resolvePath(pathString)
|
val path = resolvePath(pathString)
|
||||||
checkPathAllowed(path, pathString, operation, request)
|
checkPathAllowed(path, pathString)
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
mapExceptionToValidationResult(e)
|
mapExceptionToValidationResult(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkPathAllowed(
|
private fun checkPathAllowed(path: Path, pathString: String): ValidationResult =
|
||||||
path: Path,
|
when {
|
||||||
pathString: String,
|
|
||||||
operation: String,
|
|
||||||
request: ToolRequest,
|
|
||||||
): ValidationResult {
|
|
||||||
return when {
|
|
||||||
normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
|
normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
|
||||||
!isPathAllowed(path) ->
|
!isPathAllowed(path) -> ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
|
||||||
ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
|
|
||||||
|
|
||||||
operation == "write" && !request.parameters.containsKey("content") ->
|
|
||||||
ValidationResult.Invalid("Missing 'content' parameter for 'write' operation.")
|
|
||||||
|
|
||||||
operation != "write" && operation != "delete" ->
|
|
||||||
ValidationResult.Invalid("Unknown operation: $operation")
|
|
||||||
|
|
||||||
else -> ValidationResult.Valid
|
else -> ValidationResult.Valid
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun isPathAllowed(path: Path): Boolean =
|
private fun isPathAllowed(path: Path): Boolean =
|
||||||
PathJail.isContained(path, normalizedAllowedPaths)
|
PathJail.isContained(path, normalizedAllowedPaths)
|
||||||
@@ -139,13 +132,17 @@ class FileWriteTool(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val operation = request.parameters["operation"] as String
|
|
||||||
val pathString = request.parameters["path"] as String
|
val pathString = request.parameters["path"] as String
|
||||||
|
val content = request.parameters["content"] as String
|
||||||
val path = resolvePath(pathString)
|
val path = resolvePath(pathString)
|
||||||
val originalContent = runCatching { Files.readString(path) }.getOrDefault("")
|
val originalContent = runCatching { Files.readString(path) }.getOrDefault("")
|
||||||
|
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
performOperation(operation, path, pathString, request)
|
AtomicFileWriter.write(path, content.toByteArray(Charsets.UTF_8))
|
||||||
|
ToolResult.Success(
|
||||||
|
invocationId = request.invocationId,
|
||||||
|
output = "File written successfully to $pathString",
|
||||||
|
)
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
handleExecutionException(e, request.invocationId, pathString)
|
handleExecutionException(e, request.invocationId, pathString)
|
||||||
}
|
}
|
||||||
@@ -165,48 +162,6 @@ class FileWriteTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun performOperation(
|
|
||||||
operation: String,
|
|
||||||
path: Path,
|
|
||||||
pathString: String,
|
|
||||||
request: ToolRequest,
|
|
||||||
): ToolResult = when (operation) {
|
|
||||||
"write" -> {
|
|
||||||
val content = request.parameters["content"] as String
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
AtomicFileWriter.write(path, content.toByteArray(Charsets.UTF_8))
|
|
||||||
}
|
|
||||||
ToolResult.Success(
|
|
||||||
invocationId = request.invocationId,
|
|
||||||
output = "File written successfully to $pathString",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
"delete" -> {
|
|
||||||
if (Files.exists(path)) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
Files.deleteIfExists(path)
|
|
||||||
}
|
|
||||||
ToolResult.Success(
|
|
||||||
invocationId = request.invocationId,
|
|
||||||
output = "File deleted successfully: $pathString",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ToolResult.Failure(
|
|
||||||
invocationId = request.invocationId,
|
|
||||||
reason = "File not found: $pathString",
|
|
||||||
recoverable = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> ToolResult.Failure(
|
|
||||||
invocationId = request.invocationId,
|
|
||||||
reason = "Unknown operation: $operation",
|
|
||||||
recoverable = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleExecutionException(
|
private fun handleExecutionException(
|
||||||
e: Throwable,
|
e: Throwable,
|
||||||
invocationId: ToolInvocationId,
|
invocationId: ToolInvocationId,
|
||||||
|
|||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package com.correx.infrastructure.tools.filesystem
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ToolRequest
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import com.correx.core.tools.contract.ToolResult
|
||||||
|
import com.correx.core.tools.contract.ValidationResult
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class FileDeleteToolTest {
|
||||||
|
|
||||||
|
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
||||||
|
|
||||||
|
private fun createRequest(parameters: Map<String, String>): ToolRequest = ToolRequest(
|
||||||
|
invocationId = invocationId,
|
||||||
|
sessionId = SessionId(UUID.randomUUID().toString()),
|
||||||
|
stageId = StageId(UUID.randomUUID().toString()),
|
||||||
|
toolName = "file_delete",
|
||||||
|
parameters = parameters,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validateRequest returns Valid for existing file in allowed directory`(): Unit = runBlocking {
|
||||||
|
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||||
|
val filePath = tempDir.resolve("existing.txt")
|
||||||
|
Files.writeString(filePath, "content")
|
||||||
|
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||||
|
assertEquals(ValidationResult.Valid, tool.validateRequest(createRequest(mapOf("path" to filePath.toString()))))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validateRequest returns Invalid for missing path with actionable message`(): Unit = runBlocking {
|
||||||
|
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||||
|
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||||
|
val result = tool.validateRequest(createRequest(emptyMap()))
|
||||||
|
assertTrue(result is ValidationResult.Invalid)
|
||||||
|
assertTrue((result as ValidationResult.Invalid).reason.contains("Missing 'path'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validateRequest returns Invalid for disallowed path`(): Unit = runBlocking {
|
||||||
|
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||||
|
val otherDir = Files.createTempDirectory("other_dir")
|
||||||
|
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||||
|
val result = tool.validateRequest(createRequest(mapOf("path" to otherDir.resolve("x.txt").toString())))
|
||||||
|
assertTrue(result is ValidationResult.Invalid)
|
||||||
|
assertTrue((result as ValidationResult.Invalid).reason.contains("is not in the allowed list"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute deletes an existing file`(): Unit = runBlocking {
|
||||||
|
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||||
|
val filePath = tempDir.resolve("to_delete.txt")
|
||||||
|
Files.writeString(filePath, "content to delete")
|
||||||
|
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||||
|
|
||||||
|
val result = tool.execute(createRequest(mapOf("path" to filePath.toString())))
|
||||||
|
|
||||||
|
assertTrue(result is ToolResult.Success)
|
||||||
|
assertTrue((result as ToolResult.Success).output.startsWith("File deleted successfully: $filePath"))
|
||||||
|
assertFalse(Files.exists(filePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute returns Failure for deleting non-existent file`(): Unit = runBlocking {
|
||||||
|
val tempDir = Files.createTempDirectory("file_delete_test")
|
||||||
|
val filePath = tempDir.resolve("non_existent.txt")
|
||||||
|
val tool = FileDeleteTool(allowedPaths = setOf(tempDir))
|
||||||
|
|
||||||
|
val result = tool.execute(createRequest(mapOf("path" to filePath.toString())))
|
||||||
|
|
||||||
|
assertTrue(result is ToolResult.Failure)
|
||||||
|
val failure = result as ToolResult.Failure
|
||||||
|
assertEquals("File not found: $filePath", failure.reason)
|
||||||
|
assertFalse(failure.recoverable)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-159
@@ -8,7 +8,6 @@ import com.correx.core.tools.contract.ToolResult
|
|||||||
import com.correx.core.tools.contract.ValidationResult
|
import com.correx.core.tools.contract.ValidationResult
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
@@ -20,29 +19,20 @@ class FileWriteToolTest {
|
|||||||
private val stageId = StageId(UUID.randomUUID().toString())
|
private val stageId = StageId(UUID.randomUUID().toString())
|
||||||
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
||||||
|
|
||||||
private fun createRequest(
|
private fun createRequest(parameters: Map<String, String>): ToolRequest = ToolRequest(
|
||||||
parameters: Map<String, String>,
|
invocationId = invocationId,
|
||||||
toolName: String = "file_write",
|
sessionId = sessionId,
|
||||||
): ToolRequest {
|
stageId = stageId,
|
||||||
return ToolRequest(
|
toolName = "file_write",
|
||||||
invocationId = invocationId,
|
parameters = parameters,
|
||||||
sessionId = sessionId,
|
)
|
||||||
stageId = stageId,
|
|
||||||
toolName = toolName,
|
|
||||||
parameters = parameters,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `validateRequest returns Valid for allowed path and valid operation`(): Unit = runBlocking {
|
fun `validateRequest returns Valid for allowed path with content`(): Unit = runBlocking {
|
||||||
val tempDir = Files.createTempDirectory("file_write_test")
|
val tempDir = Files.createTempDirectory("file_write_test")
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||||
val request = createRequest(
|
val request = createRequest(
|
||||||
mapOf(
|
mapOf("path" to tempDir.resolve("file.txt").toString(), "content" to "hello"),
|
||||||
"operation" to "write",
|
|
||||||
"path" to tempDir.resolve("file.txt").toString(),
|
|
||||||
"content" to "hello",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
|
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
|
||||||
}
|
}
|
||||||
@@ -53,11 +43,7 @@ class FileWriteToolTest {
|
|||||||
val otherDir = Files.createTempDirectory("other_dir")
|
val otherDir = Files.createTempDirectory("other_dir")
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||||
val request = createRequest(
|
val request = createRequest(
|
||||||
mapOf(
|
mapOf("path" to otherDir.resolve("file.txt").toString(), "content" to "hello"),
|
||||||
"operation" to "write",
|
|
||||||
"path" to otherDir.resolve("file.txt").toString(),
|
|
||||||
"content" to "hello",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
val result = tool.validateRequest(request)
|
val result = tool.validateRequest(request)
|
||||||
assertTrue(result is ValidationResult.Invalid)
|
assertTrue(result is ValidationResult.Invalid)
|
||||||
@@ -65,171 +51,58 @@ class FileWriteToolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `validateRequest returns Invalid for missing operation`(): Unit = runBlocking {
|
fun `validateRequest returns Invalid for missing path with actionable message`(): Unit = runBlocking {
|
||||||
val tempDir = Files.createTempDirectory("file_write_test")
|
val tempDir = Files.createTempDirectory("file_write_test")
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||||
val request = createRequest(
|
val result = tool.validateRequest(createRequest(mapOf("content" to "hello")))
|
||||||
mapOf(
|
|
||||||
"path" to tempDir.resolve("file.txt").toString(),
|
|
||||||
"content" to "hello",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val result = tool.validateRequest(request)
|
|
||||||
assertTrue(result is ValidationResult.Invalid)
|
assertTrue(result is ValidationResult.Invalid)
|
||||||
assertEquals(
|
val reason = (result as ValidationResult.Invalid).reason
|
||||||
"Missing 'operation' parameter. Expected 'write' or 'delete'.",
|
assertTrue(reason.contains("Missing 'path'"))
|
||||||
(result as ValidationResult.Invalid).reason,
|
// actionable: shows the required call shape
|
||||||
)
|
assertTrue(reason.contains("\"content\""))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `validateRequest returns Invalid for missing path`(): Unit = runBlocking {
|
fun `validateRequest returns Invalid for missing content with actionable message`(): Unit = runBlocking {
|
||||||
val tempDir = Files.createTempDirectory("file_write_test")
|
val tempDir = Files.createTempDirectory("file_write_test")
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||||
val request = createRequest(
|
val request = createRequest(mapOf("path" to tempDir.resolve("file.txt").toString()))
|
||||||
mapOf(
|
|
||||||
"operation" to "write",
|
|
||||||
"content" to "hello",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val result = tool.validateRequest(request)
|
val result = tool.validateRequest(request)
|
||||||
assertTrue(result is ValidationResult.Invalid)
|
assertTrue(result is ValidationResult.Invalid)
|
||||||
assertEquals("Missing 'path' parameter. Expected String.", (result as ValidationResult.Invalid).reason)
|
val reason = (result as ValidationResult.Invalid).reason
|
||||||
|
assertTrue(reason.contains("Missing 'content'"))
|
||||||
|
assertTrue(reason.contains("file_write"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `validateRequest returns Invalid for missing content on write`(): Unit = runBlocking {
|
fun `execute writes content and attaches a diff`(): Unit = runBlocking {
|
||||||
val tempDir = Files.createTempDirectory("file_write_test")
|
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
|
||||||
val request = createRequest(
|
|
||||||
mapOf(
|
|
||||||
"operation" to "write",
|
|
||||||
"path" to tempDir.resolve("file.txt").toString(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val result = tool.validateRequest(request)
|
|
||||||
assertTrue(result is ValidationResult.Invalid)
|
|
||||||
assertEquals("Missing 'content' parameter for 'write' operation.", (result as ValidationResult.Invalid).reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `validateRequest returns Invalid for unknown operation`(): Unit = runBlocking {
|
|
||||||
val tempDir = Files.createTempDirectory("file_write_test")
|
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
|
||||||
val request = createRequest(
|
|
||||||
mapOf(
|
|
||||||
"operation" to "unknown",
|
|
||||||
"path" to tempDir.resolve("file.txt").toString(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val result = tool.validateRequest(request)
|
|
||||||
assertTrue(result is ValidationResult.Invalid)
|
|
||||||
assertEquals("Unknown operation: unknown", (result as ValidationResult.Invalid).reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `execute returns Success for write operation`(): Unit = runBlocking {
|
|
||||||
val tempDir = Files.createTempDirectory("file_write_test")
|
val tempDir = Files.createTempDirectory("file_write_test")
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||||
val filePath = tempDir.resolve("test.txt")
|
val filePath = tempDir.resolve("test.txt")
|
||||||
val content = "Hello, FileWriteTool!"
|
val content = "Hello, FileWriteTool!"
|
||||||
val request = createRequest(
|
val request = createRequest(mapOf("path" to filePath.toString(), "content" to content))
|
||||||
mapOf(
|
|
||||||
"operation" to "write",
|
|
||||||
"path" to filePath.toString(),
|
|
||||||
"content" to content,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = tool.execute(request)
|
val result = tool.execute(request)
|
||||||
|
|
||||||
assertTrue(result is ToolResult.Success)
|
assertTrue(result is ToolResult.Success)
|
||||||
val success = result as ToolResult.Success
|
val success = result as ToolResult.Success
|
||||||
// Output starts with the status line; a unified diff of the change is appended (F-019).
|
|
||||||
assertTrue(success.output.startsWith("File written successfully to $filePath"))
|
assertTrue(success.output.startsWith("File written successfully to $filePath"))
|
||||||
assertTrue(success.metadata.containsKey("diff"))
|
assertTrue(success.metadata.containsKey("diff"))
|
||||||
assertEquals(invocationId, success.invocationId)
|
assertEquals(invocationId, success.invocationId)
|
||||||
assertEquals(content, Files.readString(filePath))
|
assertEquals(content, Files.readString(filePath))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `execute returns Success for delete operation`(): Unit = runBlocking {
|
|
||||||
val tempDir = Files.createTempDirectory("file_write_test")
|
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
|
||||||
val filePath = tempDir.resolve("to_delete.txt")
|
|
||||||
Files.writeString(filePath, "content to delete")
|
|
||||||
|
|
||||||
val request = createRequest(
|
|
||||||
mapOf(
|
|
||||||
"operation" to "delete",
|
|
||||||
"path" to filePath.toString(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = tool.execute(request)
|
|
||||||
assertTrue(result is ToolResult.Success)
|
|
||||||
val success = result as ToolResult.Success
|
|
||||||
assertTrue(success.output.startsWith("File deleted successfully: $filePath"))
|
|
||||||
assertEquals(invocationId, success.invocationId)
|
|
||||||
assertFalse(Files.exists(filePath))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `execute returns Failure for deleting non-existent file`(): Unit = runBlocking {
|
|
||||||
val tempDir = Files.createTempDirectory("file_write_test")
|
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
|
||||||
val filePath = tempDir.resolve("non_existent.txt")
|
|
||||||
|
|
||||||
val request = createRequest(
|
|
||||||
mapOf(
|
|
||||||
"operation" to "delete",
|
|
||||||
"path" to filePath.toString(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = tool.execute(request)
|
|
||||||
|
|
||||||
assertTrue(result is ToolResult.Failure)
|
|
||||||
val failure = result as ToolResult.Failure
|
|
||||||
assertEquals("File not found: $filePath", failure.reason)
|
|
||||||
assertFalse(failure.recoverable)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `execute returns Failure for disallowed path`(): Unit = runBlocking {
|
fun `execute returns Failure for disallowed path`(): Unit = runBlocking {
|
||||||
val tempDir = Files.createTempDirectory("file_write_test")
|
val tempDir = Files.createTempDirectory("file_write_test")
|
||||||
val otherDir = Files.createTempDirectory("other_dir")
|
val otherDir = Files.createTempDirectory("other_dir")
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||||
val filePath = otherDir.resolve("illegal.txt")
|
|
||||||
|
|
||||||
val request = createRequest(
|
val request = createRequest(
|
||||||
mapOf(
|
mapOf("path" to otherDir.resolve("illegal.txt").toString(), "content" to "illegal"),
|
||||||
"operation" to "write",
|
|
||||||
"path" to filePath.toString(),
|
|
||||||
"content" to "illegal",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = tool.execute(request)
|
val result = tool.execute(request)
|
||||||
|
|
||||||
assertTrue(result is ToolResult.Failure)
|
assertTrue(result is ToolResult.Failure)
|
||||||
val failure = result as ToolResult.Failure
|
assertTrue((result as ToolResult.Failure).reason.contains("is not in the allowed list"))
|
||||||
assertTrue(failure.reason.contains("is not in the allowed list"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `validateRequest returns Valid for existing file in allowed directory`(): Unit = runBlocking {
|
|
||||||
val tempDir = Files.createTempDirectory("file_write_test_existing")
|
|
||||||
val filePath = tempDir.resolve("existing.txt")
|
|
||||||
Files.writeString(filePath, "content")
|
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
|
||||||
val request = createRequest(
|
|
||||||
mapOf(
|
|
||||||
"operation" to "delete",
|
|
||||||
"path" to filePath.toString(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -237,9 +110,7 @@ class FileWriteToolTest {
|
|||||||
val tempDir = Files.createTempDirectory("file_write_atomic")
|
val tempDir = Files.createTempDirectory("file_write_atomic")
|
||||||
val target = tempDir.resolve("nested/deep/file.txt")
|
val target = tempDir.resolve("nested/deep/file.txt")
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||||
val request = createRequest(
|
val request = createRequest(mapOf("path" to target.toString(), "content" to "hello"))
|
||||||
mapOf("operation" to "write", "path" to target.toString(), "content" to "hello"),
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = tool.execute(request)
|
val result = tool.execute(request)
|
||||||
|
|
||||||
@@ -254,15 +125,12 @@ class FileWriteToolTest {
|
|||||||
val target = tempDir.resolve("file.txt")
|
val target = tempDir.resolve("file.txt")
|
||||||
Files.writeString(target, "old")
|
Files.writeString(target, "old")
|
||||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||||
val request = createRequest(
|
val request = createRequest(mapOf("path" to target.toString(), "content" to "new"))
|
||||||
mapOf("operation" to "write", "path" to target.toString(), "content" to "new"),
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = tool.execute(request)
|
val result = tool.execute(request)
|
||||||
|
|
||||||
assertTrue(result is ToolResult.Success)
|
assertTrue(result is ToolResult.Success)
|
||||||
assertEquals("new", Files.readString(target))
|
assertEquals("new", Files.readString(target))
|
||||||
// the staged temp file must not survive the swap
|
|
||||||
val leftovers = Files.list(tempDir).use { stream ->
|
val leftovers = Files.list(tempDir).use { stream ->
|
||||||
stream.filter { it.fileName.toString().endsWith(".tmp") }.count()
|
stream.filter { it.fileName.toString().endsWith(".tmp") }.count()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.correx.infrastructure.tools
|
package com.correx.infrastructure.tools
|
||||||
|
|
||||||
import com.correx.core.tools.contract.Tool
|
import com.correx.core.tools.contract.Tool
|
||||||
|
import com.correx.infrastructure.tools.filesystem.FileDeleteTool
|
||||||
import com.correx.infrastructure.tools.filesystem.FileEditTool
|
import com.correx.infrastructure.tools.filesystem.FileEditTool
|
||||||
import com.correx.infrastructure.tools.filesystem.FileReadTool
|
import com.correx.infrastructure.tools.filesystem.FileReadTool
|
||||||
import com.correx.infrastructure.tools.filesystem.FileWriteTool
|
import com.correx.infrastructure.tools.filesystem.FileWriteTool
|
||||||
@@ -81,6 +82,14 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
|
|||||||
workingDir = fileWrite.workingDir,
|
workingDir = fileWrite.workingDir,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// file_delete is the explicit, separately-named deletion capability split out of
|
||||||
|
// file_write; it shares the writer's path jail and is gated by the same fileWrite toggle.
|
||||||
|
add(
|
||||||
|
FileDeleteTool(
|
||||||
|
allowedPaths = fileWrite.allowedPaths,
|
||||||
|
workingDir = fileWrite.workingDir,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (fileEdit.enabled) {
|
if (fileEdit.enabled) {
|
||||||
add(
|
add(
|
||||||
|
|||||||
+1
-1
@@ -65,7 +65,7 @@ class SandboxedToolExecutorFileMutationTest {
|
|||||||
|
|
||||||
private fun writeRequest(path: String, content: String) = ToolRequest(
|
private fun writeRequest(path: String, content: String) = ToolRequest(
|
||||||
ToolInvocationId("inv"), SessionId("s"), StageId("st"), "file_write",
|
ToolInvocationId("inv"), SessionId("s"), StageId("st"), "file_write",
|
||||||
mapOf("operation" to "write", "path" to path, "content" to content),
|
mapOf("path" to path, "content" to content),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun executor(tool: FileWriteTool, store: FakeArtifactStore, events: CapturingEventStore) =
|
private fun executor(tool: FileWriteTool, store: FakeArtifactStore, events: CapturingEventStore) =
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ include ':infrastructure:persistence'
|
|||||||
include ':infrastructure:inference'
|
include ':infrastructure:inference'
|
||||||
include ':infrastructure:inference:commons'
|
include ':infrastructure:inference:commons'
|
||||||
include ':infrastructure:inference:llama_cpp'
|
include ':infrastructure:inference:llama_cpp'
|
||||||
|
include ':infrastructure:inference:openai_compat'
|
||||||
include ':infrastructure:router'
|
include ':infrastructure:router'
|
||||||
include ':infrastructure:router:turbovec'
|
include ':infrastructure:router:turbovec'
|
||||||
include ':infrastructure:tools'
|
include ':infrastructure:tools'
|
||||||
|
|||||||
Reference in New Issue
Block a user