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
|
||||
|
||||
### 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
|
||||
- `GET/POST /tasks` — task listing and management
|
||||
- `GET/POST /providers` — provider configuration
|
||||
- `GET/POST /workflows` — workflow management
|
||||
- `GET /health` — health report (probes: event-store, llama-server, disk watermark)
|
||||
- `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`)
|
||||
- **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:inference')
|
||||
implementation project(':infrastructure:inference:llama_cpp')
|
||||
implementation project(':infrastructure:inference:openai_compat')
|
||||
implementation project(':infrastructure:inference:commons')
|
||||
implementation project(':core:router')
|
||||
implementation project(':core:tools')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.correx.apps.server
|
||||
|
||||
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.sessionRoutes
|
||||
import com.correx.apps.server.routes.taskRoutes
|
||||
@@ -38,6 +39,7 @@ fun Application.configureServer(module: ServerModule) {
|
||||
|
||||
val globalStreamHandler = GlobalStreamHandler(module)
|
||||
val healthInspection = HealthInspectionService(module.eventStore)
|
||||
val toolReliability = ToolReliabilityInspectionService(module.eventStore)
|
||||
|
||||
routing {
|
||||
get("/health") {
|
||||
@@ -51,6 +53,12 @@ fun Application.configureServer(module: ServerModule) {
|
||||
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") {
|
||||
globalStreamHandler.handle(this)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ import com.correx.core.events.types.SessionId
|
||||
import com.correx.infrastructure.InfrastructureModule
|
||||
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
||||
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.AmdResourceProbe
|
||||
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.UnavailableProbe
|
||||
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.task.TaskTools
|
||||
import com.correx.infrastructure.tools.FileEditConfig
|
||||
@@ -148,12 +147,9 @@ fun main() {
|
||||
firstProvider = managedProvider
|
||||
infraRegistry = InfrastructureModule.createProviderRegistry(listOf(managedProvider))
|
||||
// Also register any static providers from [[providers]] (non-managed remotes)
|
||||
correxConfig.providers.mapNotNull { providerConfig ->
|
||||
when (providerConfig.type.lowercase()) {
|
||||
"llamacpp" -> buildProviderFromConfig(providerConfig)
|
||||
else -> { log.error("Unknown provider type: {}", providerConfig.type); null }
|
||||
}
|
||||
}.forEach { infraRegistry.register(it) }
|
||||
correxConfig.providers
|
||||
.mapNotNull { providerConfig -> buildProviderFromConfig(providerConfig) }
|
||||
.forEach { infraRegistry.register(it) }
|
||||
// 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.
|
||||
val managedRouter = ManagedInferenceRouter(modelManager, descriptors, targetModelConfig.id)
|
||||
@@ -174,7 +170,7 @@ fun main() {
|
||||
require(staticProviders.isNotEmpty()) { "At least one provider must be configured" }
|
||||
firstProvider = staticProviders.first()
|
||||
infraRegistry = InfrastructureModule.createProviderRegistry(staticProviders)
|
||||
inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy())
|
||||
inferenceRouter = DefaultInferenceRouter(infraRegistry, CapabilityAwareRoutingStrategy())
|
||||
llamaBaseUrl = correxConfig.providers.firstOrNull()?.url
|
||||
?: System.getenv("CORREX_LLAMA_URL")
|
||||
?: "http://127.0.0.1:10000"
|
||||
@@ -566,7 +562,9 @@ fun main() {
|
||||
module.start()
|
||||
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? {
|
||||
@@ -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()) {
|
||||
log.info("Loading {} provider(s) from config", config.providers.size)
|
||||
config.providers.mapNotNull { providerConfig ->
|
||||
when (providerConfig.type.lowercase()) {
|
||||
"llamacpp" -> buildProviderFromConfig(providerConfig)
|
||||
else -> {
|
||||
log.error("Unknown provider type: {}", providerConfig.type)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
config.providers.mapNotNull { providerConfig -> buildProviderFromConfig(providerConfig) }
|
||||
} else {
|
||||
log.info("No providers in config; using env var fallback")
|
||||
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)
|
||||
return InfrastructureModule.createLlamaCppProvider(
|
||||
modelId = config.modelId,
|
||||
modelPath = config.modelPath,
|
||||
baseUrl = config.url,
|
||||
capabilities = capabilities,
|
||||
)
|
||||
return when (config.type.lowercase()) {
|
||||
"llamacpp" -> InfrastructureModule.createLlamaCppProvider(
|
||||
modelId = config.modelId,
|
||||
modelPath = config.modelPath,
|
||||
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> {
|
||||
@@ -740,6 +764,7 @@ private fun buildToolConfig(
|
||||
fileRead = FileReadConfig(
|
||||
enabled = toolsConfig.fileReadEnabled,
|
||||
allowedPaths = allowed,
|
||||
workingDir = workingDir,
|
||||
),
|
||||
fileWrite = FileWriteConfig(
|
||||
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.ws.SessionStreamHandler
|
||||
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.types.SessionId
|
||||
import com.correx.core.utils.TypeId
|
||||
@@ -39,7 +40,13 @@ data class EventRow(
|
||||
)
|
||||
|
||||
@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
|
||||
data class SessionSummaryResponse(
|
||||
@@ -108,6 +115,9 @@ private fun Route.startSessionRoute(module: ServerModule) {
|
||||
val graph = module.workflowRegistry.find(body.workflowId)
|
||||
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}")
|
||||
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)
|
||||
call.respond(HttpStatusCode.Accepted, StartSessionResponse(sessionId.value))
|
||||
}
|
||||
|
||||
@@ -537,6 +537,8 @@ object ConfigLoader {
|
||||
modelPath = modelPath,
|
||||
url = url,
|
||||
capabilities = capabilities,
|
||||
apiKey = asString(providerMap["api_key"], ""),
|
||||
apiKeyEnv = asString(providerMap["api_key_env"], ""),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,11 @@ data class ProviderConfig(
|
||||
val modelPath: String = "",
|
||||
val url: String = "http://127.0.0.1:10000",
|
||||
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
|
||||
|
||||
@@ -113,6 +113,8 @@ object CorrexConfigWriter {
|
||||
b.kv("model_path", str(p.modelPath))
|
||||
b.kv("url", str(p.url))
|
||||
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 ->
|
||||
|
||||
@@ -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.
|
||||
- `OrchestrationState` / `OrchestrationReducer` (`DefaultOrchestrationReducer`) / `OrchestrationProjector` / `OrchestrationRepository` — standard event-sourcing stack for orchestration state.
|
||||
- `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).
|
||||
- `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
|
||||
- `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.ToolCallRequest
|
||||
import com.correx.core.inference.ToolDefinition
|
||||
import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.core.inference.ToolFunction
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
@@ -561,7 +562,8 @@ abstract class SessionOrchestrator(
|
||||
continue
|
||||
}
|
||||
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:") }
|
||||
if (fatalEntry != null) {
|
||||
emitProcessResultEvents(sessionId, stageId, stageConfig)
|
||||
@@ -654,12 +656,33 @@ abstract class SessionOrchestrator(
|
||||
}
|
||||
|
||||
@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(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
toolCalls: List<ToolCallRequest>,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
approvalMode: ApprovalMode,
|
||||
): List<ContextEntry> {
|
||||
val executor = effectives.executor ?: return emptyList()
|
||||
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 approvalCtx = ApprovalContext(
|
||||
identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId),
|
||||
mode = ApprovalMode.PROMPT,
|
||||
mode = approvalMode,
|
||||
)
|
||||
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
|
||||
val toolPreview = computeToolPreview(toolCall.function.name, parameters)
|
||||
@@ -968,7 +991,7 @@ abstract class SessionOrchestrator(
|
||||
?: result.output
|
||||
is ToolResult.Failure -> {
|
||||
if (!result.recoverable) "FATAL: ${result.reason}"
|
||||
else "ERROR: ${result.reason}"
|
||||
else "ERROR: ${result.reason}${toolArgsHint(tool)}"
|
||||
}
|
||||
}
|
||||
val resultEntry = ContextEntry(
|
||||
@@ -1772,7 +1795,12 @@ abstract class SessionOrchestrator(
|
||||
responseFormat: ResponseFormat = ResponseFormat.Text,
|
||||
effectives: RunEffectives = RunEffectives(toolRegistry, toolExecutor, workspacePolicy),
|
||||
): 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(
|
||||
"[Orchestrator] inference session={} stage={} provider={} timeoutMs={}",
|
||||
sessionId.value, stageId.value, provider.id.value, timeoutMs,
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies {
|
||||
implementation project(":infrastructure:inference")
|
||||
implementation project(":infrastructure:inference:commons")
|
||||
implementation project(":infrastructure:inference:llama_cpp")
|
||||
implementation project(":infrastructure:inference:openai_compat")
|
||||
implementation project(":infrastructure:persistence")
|
||||
implementation project(":infrastructure:router:turbovec")
|
||||
implementation project(":infrastructure:tools")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
|
||||
@@ -11,10 +11,11 @@ Adapter for LLM inference backends. Implements `core:inference` interfaces. No d
|
||||
## Local Contracts
|
||||
|
||||
- `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.
|
||||
- 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).
|
||||
- `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
|
||||
|
||||
@@ -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:commons:test --rerun-tasks
|
||||
./gradlew :infrastructure:inference:llama_cpp:test --rerun-tasks
|
||||
./gradlew :infrastructure:inference:openai_compat:test --rerun-tasks
|
||||
```
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
- `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)
|
||||
- `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.LlamaCppEmbedder
|
||||
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
||||
import com.correx.infrastructure.inference.openai.OpenAiCompatInferenceProvider
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import com.correx.infrastructure.router.turbovec.TurboVecL3MemoryStore
|
||||
@@ -127,6 +128,20 @@ object InfrastructureModule {
|
||||
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(
|
||||
settings: ModelsSettings,
|
||||
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).
|
||||
- `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.
|
||||
- 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
|
||||
|
||||
@@ -30,4 +31,4 @@ Standard adapter rules apply (see parent `AGENTS.md`). Network calls (web tools)
|
||||
|
||||
## 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.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(
|
||||
allowedPaths: Set<Path> = emptySet(),
|
||||
private val workingDir: Path? = null,
|
||||
@@ -33,7 +39,7 @@ class FileWriteTool(
|
||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||
|
||||
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 {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
@@ -43,11 +49,7 @@ class FileWriteTool(
|
||||
}
|
||||
putJsonObject("content") {
|
||||
put("type", "string")
|
||||
put("description", "File content")
|
||||
}
|
||||
putJsonObject("operation") {
|
||||
put("type", "string")
|
||||
put("description", "Either 'write' or 'delete'")
|
||||
put("description", "The full file content to write")
|
||||
}
|
||||
}
|
||||
put(
|
||||
@@ -55,7 +57,6 @@ class FileWriteTool(
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("path"))
|
||||
add(JsonPrimitive("content"))
|
||||
add(JsonPrimitive("operation"))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -78,45 +79,37 @@ class FileWriteTool(
|
||||
}
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
val operation = request.parameters["operation"] as? String
|
||||
val pathString = request.parameters["path"] as? String
|
||||
val hasContent = request.parameters.containsKey("content")
|
||||
|
||||
return when {
|
||||
operation == null ->
|
||||
ValidationResult.Invalid("Missing 'operation' parameter. Expected 'write' or 'delete'.")
|
||||
|
||||
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 {
|
||||
val path = resolvePath(pathString)
|
||||
checkPathAllowed(path, pathString, operation, request)
|
||||
checkPathAllowed(path, pathString)
|
||||
}.getOrElse { e ->
|
||||
mapExceptionToValidationResult(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPathAllowed(
|
||||
path: Path,
|
||||
pathString: String,
|
||||
operation: String,
|
||||
request: ToolRequest,
|
||||
): ValidationResult {
|
||||
return when {
|
||||
private fun checkPathAllowed(path: Path, pathString: String): ValidationResult =
|
||||
when {
|
||||
normalizedAllowedPaths.isEmpty() -> ValidationResult.Invalid("No paths are allowed.")
|
||||
!isPathAllowed(path) ->
|
||||
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")
|
||||
|
||||
!isPathAllowed(path) -> ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
|
||||
else -> ValidationResult.Valid
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPathAllowed(path: Path): Boolean =
|
||||
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 content = request.parameters["content"] as String
|
||||
val path = resolvePath(pathString)
|
||||
val originalContent = runCatching { Files.readString(path) }.getOrDefault("")
|
||||
|
||||
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 ->
|
||||
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(
|
||||
e: Throwable,
|
||||
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 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
|
||||
@@ -20,29 +19,20 @@ class FileWriteToolTest {
|
||||
private val stageId = StageId(UUID.randomUUID().toString())
|
||||
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
||||
|
||||
private fun createRequest(
|
||||
parameters: Map<String, String>,
|
||||
toolName: String = "file_write",
|
||||
): ToolRequest {
|
||||
return ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = toolName,
|
||||
parameters = parameters,
|
||||
)
|
||||
}
|
||||
private fun createRequest(parameters: Map<String, String>): ToolRequest = ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = "file_write",
|
||||
parameters = parameters,
|
||||
)
|
||||
|
||||
@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 tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "write",
|
||||
"path" to tempDir.resolve("file.txt").toString(),
|
||||
"content" to "hello",
|
||||
),
|
||||
mapOf("path" to tempDir.resolve("file.txt").toString(), "content" to "hello"),
|
||||
)
|
||||
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
|
||||
}
|
||||
@@ -53,11 +43,7 @@ class FileWriteToolTest {
|
||||
val otherDir = Files.createTempDirectory("other_dir")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "write",
|
||||
"path" to otherDir.resolve("file.txt").toString(),
|
||||
"content" to "hello",
|
||||
),
|
||||
mapOf("path" to otherDir.resolve("file.txt").toString(), "content" to "hello"),
|
||||
)
|
||||
val result = tool.validateRequest(request)
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
@@ -65,171 +51,58 @@ class FileWriteToolTest {
|
||||
}
|
||||
|
||||
@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 tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"path" to tempDir.resolve("file.txt").toString(),
|
||||
"content" to "hello",
|
||||
),
|
||||
)
|
||||
val result = tool.validateRequest(request)
|
||||
val result = tool.validateRequest(createRequest(mapOf("content" to "hello")))
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertEquals(
|
||||
"Missing 'operation' parameter. Expected 'write' or 'delete'.",
|
||||
(result as ValidationResult.Invalid).reason,
|
||||
)
|
||||
val reason = (result as ValidationResult.Invalid).reason
|
||||
assertTrue(reason.contains("Missing 'path'"))
|
||||
// actionable: shows the required call shape
|
||||
assertTrue(reason.contains("\"content\""))
|
||||
}
|
||||
|
||||
@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 tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "write",
|
||||
"content" to "hello",
|
||||
),
|
||||
)
|
||||
val request = createRequest(mapOf("path" to tempDir.resolve("file.txt").toString()))
|
||||
val result = tool.validateRequest(request)
|
||||
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
|
||||
fun `validateRequest returns Invalid for missing content on write`(): 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 {
|
||||
fun `execute writes content and attaches a diff`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val filePath = tempDir.resolve("test.txt")
|
||||
val content = "Hello, FileWriteTool!"
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "write",
|
||||
"path" to filePath.toString(),
|
||||
"content" to content,
|
||||
),
|
||||
)
|
||||
val request = createRequest(mapOf("path" to filePath.toString(), "content" to content))
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is 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.metadata.containsKey("diff"))
|
||||
assertEquals(invocationId, success.invocationId)
|
||||
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
|
||||
fun `execute returns Failure for disallowed path`(): Unit = runBlocking {
|
||||
val tempDir = Files.createTempDirectory("file_write_test")
|
||||
val otherDir = Files.createTempDirectory("other_dir")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val filePath = otherDir.resolve("illegal.txt")
|
||||
|
||||
val request = createRequest(
|
||||
mapOf(
|
||||
"operation" to "write",
|
||||
"path" to filePath.toString(),
|
||||
"content" to "illegal",
|
||||
),
|
||||
mapOf("path" to otherDir.resolve("illegal.txt").toString(), "content" to "illegal"),
|
||||
)
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
val failure = result as ToolResult.Failure
|
||||
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))
|
||||
assertTrue((result as ToolResult.Failure).reason.contains("is not in the allowed list"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -237,9 +110,7 @@ class FileWriteToolTest {
|
||||
val tempDir = Files.createTempDirectory("file_write_atomic")
|
||||
val target = tempDir.resolve("nested/deep/file.txt")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf("operation" to "write", "path" to target.toString(), "content" to "hello"),
|
||||
)
|
||||
val request = createRequest(mapOf("path" to target.toString(), "content" to "hello"))
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
@@ -254,15 +125,12 @@ class FileWriteToolTest {
|
||||
val target = tempDir.resolve("file.txt")
|
||||
Files.writeString(target, "old")
|
||||
val tool = FileWriteTool(allowedPaths = setOf(tempDir))
|
||||
val request = createRequest(
|
||||
mapOf("operation" to "write", "path" to target.toString(), "content" to "new"),
|
||||
)
|
||||
val request = createRequest(mapOf("path" to target.toString(), "content" to "new"))
|
||||
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Success)
|
||||
assertEquals("new", Files.readString(target))
|
||||
// the staged temp file must not survive the swap
|
||||
val leftovers = Files.list(tempDir).use { stream ->
|
||||
stream.filter { it.fileName.toString().endsWith(".tmp") }.count()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
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.FileReadTool
|
||||
import com.correx.infrastructure.tools.filesystem.FileWriteTool
|
||||
@@ -81,6 +82,14 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
|
||||
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) {
|
||||
add(
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ class SandboxedToolExecutorFileMutationTest {
|
||||
|
||||
private fun writeRequest(path: String, content: String) = ToolRequest(
|
||||
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) =
|
||||
|
||||
@@ -33,6 +33,7 @@ include ':infrastructure:persistence'
|
||||
include ':infrastructure:inference'
|
||||
include ':infrastructure:inference:commons'
|
||||
include ':infrastructure:inference:llama_cpp'
|
||||
include ':infrastructure:inference:openai_compat'
|
||||
include ':infrastructure:router'
|
||||
include ':infrastructure:router:turbovec'
|
||||
include ':infrastructure:tools'
|
||||
|
||||
Reference in New Issue
Block a user