685 lines
26 KiB
Markdown
685 lines
26 KiB
Markdown
# Epic 11 — Infrastructure Layer
|
|
|
|
**date:** 2026-05-12
|
|
**scope:** `infrastructure/` — persistence, inference, tools, model management
|
|
**status:** ready to implement
|
|
|
|
---
|
|
|
|
## resume instructions
|
|
|
|
1. open project at `/home/kami/Programs/correx`
|
|
2. read this file top to bottom before writing any code
|
|
3. complete tasks in order — hard boundary after task 3 before tools work begins
|
|
4. do NOT explore beyond files listed in each task
|
|
5. in-memory stores and mocks are NOT acceptable — this epic is real implementations only
|
|
|
|
---
|
|
|
|
## prerequisite — `:core:tools` contract extension (before epic 11)
|
|
|
|
these three additions belong in `:core:tools`, not infrastructure. complete them first as a separate commit before touching any `infrastructure/` code.
|
|
|
|
### `ToolResult`
|
|
|
|
**file:** `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolResult.kt`
|
|
|
|
```kotlin
|
|
sealed interface ToolResult {
|
|
data class Success(
|
|
val invocationId: ToolInvocationId,
|
|
val output: String,
|
|
val metadata: Map<String, String> = emptyMap(),
|
|
) : ToolResult
|
|
|
|
data class Failure(
|
|
val invocationId: ToolInvocationId,
|
|
val reason: String,
|
|
val recoverable: Boolean,
|
|
) : ToolResult
|
|
}
|
|
```
|
|
|
|
### `ToolExecutor`
|
|
|
|
**file:** `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolExecutor.kt`
|
|
|
|
```kotlin
|
|
interface ToolExecutor {
|
|
suspend fun execute(request: ToolRequest): ToolResult
|
|
}
|
|
```
|
|
|
|
### `FileAffectingTool`
|
|
|
|
**file:** `core/tools/src/main/kotlin/com/correx/core/tools/contract/FileAffectingTool.kt`
|
|
|
|
marker interface for tools that touch the filesystem. sandbox uses this to know which paths to back up:
|
|
|
|
```kotlin
|
|
interface FileAffectingTool : Tool {
|
|
fun affectedPaths(request: ToolRequest): Set<Path>
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## scope (what IS in epic 11)
|
|
|
|
- `SqliteEventStore` — already done, skip
|
|
- `LlamaCppInferenceProvider` — OpenAI-compatible REST client
|
|
- `LlamaCppTokenizer`
|
|
- `ModelManager` + `ManagedInferenceProvider` — load/unload/health/residency
|
|
- `ToolRegistry` — immutable after bootstrap
|
|
- `SandboxedToolExecutor` — `/tmp` isolation + backup/restore + approval gating
|
|
- `ShellTool` — argv-based, allowlist-gated
|
|
- `FilesystemTool` — path-allowlisted, traversal-safe
|
|
- `InfrastructureModule` — wiring
|
|
|
|
## scope (what is NOT in epic 11)
|
|
|
|
- postgres, snapshots, migrations
|
|
- ollama / vllm providers
|
|
- docker / network tools
|
|
- telemetry exporters
|
|
- process namespace isolation (seccomp/unshare) — parked
|
|
- scheduler / concurrency / backpressure
|
|
- CLI / API wiring
|
|
|
|
---
|
|
|
|
## dependency inventory
|
|
|
|
| module | key types | concrete import | file path |
|
|
|---|---|---|---|
|
|
| `:core:events` | `EventStore` | `import com.correx.core.events.stores.EventStore` | `core/events/src/main/kotlin/com/correx/core/events/stores/EventStore.kt` |
|
|
| `:core:events` | `NewEvent` | `import com.correx.core.events.events.NewEvent` | `core/events/src/main/kotlin/com/correx/core/events/events/EventEnvelope.kt` |
|
|
| `:core:events` | `EventMetadata` | `import com.correx.core.events.events.EventMetadata` | `core/events/src/main/kotlin/com/correx/core/events/events/EventMetadata.kt` |
|
|
| `:core:events` | `TypeId` | `import com.correx.core.utils.TypeId` | `core/events/src/main/kotlin/com/correx/core/utils/TypeId.kt` |
|
|
| `:core:inference` | `InferenceProvider` | `import com.correx.core.inference.InferenceProvider` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceProvider.kt` |
|
|
| `:core:inference` | `InferenceRequest` | `import com.correx.core.inference.InferenceRequest` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceRequest.kt` |
|
|
| `:core:inference` | `InferenceResponse` | `import com.correx.core.inference.InferenceResponse` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceResponse.kt` |
|
|
| `:core:inference` | `InferenceRouter` | `import com.correx.core.inference.InferenceRouter` | `core/inference/src/main/kotlin/com/correx/core/inference/InferenceRouter.kt` |
|
|
| `:core:inference` | `GenerationConfig` | `import com.correx.core.inference.GenerationConfig` | `core/inference/src/main/kotlin/com/correx/core/inference/GenerationConfig.kt` |
|
|
| `:core:inference` | `TokenUsage` | `import com.correx.core.inference.TokenUsage` | `core/events/src/main/kotlin/com/correx/core/inference/TokenUsage.kt` |
|
|
| `:core:inference` | `FinishReason` | `import com.correx.core.inference.FinishReason` | `core/inference/src/main/kotlin/com/correx/core/inference/FinishReason.kt` |
|
|
| `:core:inference` | `ProviderId` | `import com.correx.core.events.types.ProviderId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
|
|
| `:core:inference` | `ModelCapability` | `import com.correx.core.inference.ModelCapability` | `core/inference/src/main/kotlin/com/correx/core/inference/ModelCapability.kt` |
|
|
| `:core:inference` | `CapabilityScore` | `import com.correx.core.inference.CapabilityScore` | `core/inference/src/main/kotlin/com/correx/core/inference/ModelCapability.kt` |
|
|
| `:core:inference` | `ProviderHealth` | `import com.correx.core.inference.ProviderHealth` | `core/inference/src/main/kotlin/com/correx/core/inference/ProviderHealth.kt` |
|
|
| `:core:approvals` | `ApprovalEngine` | `import com.correx.core.approvals.domain.ApprovalEngine` | `core/approvals/src/main/kotlin/com/correx/core/approvals/domain/ApprovalEngine.kt` |
|
|
| `:core:approvals` | `ApprovalRequest` | `import com.correx.core.approvals.model.DomainApprovalRequest` | `core/approvals/src/main/kotlin/com/correx/core/approvals/model/DomainApprovalRequest.kt` |
|
|
| `:core:approvals` | `ApprovalContext` | `import com.correx.core.approvals.model.ApprovalContext` | `core/approvals/src/main/kotlin/com/correx/core/approvals/model/ApprovalContext.kt` |
|
|
| `:core:approvals` | `ApprovalMode` | `import com.correx.core.sessions.ApprovalMode` | `core/sessions/src/main/kotlin/com/correx/core/sessions/ApprovalMode.kt` |
|
|
| `:core:approvals` | `Tier` | `import com.correx.core.approvals.Tier` | `core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt` |
|
|
| `:core:tools` | `Tool` | `import com.correx.core.tools.contract.Tool` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/Tool.kt` |
|
|
| `:core:tools` | `ToolRequest` | `import com.correx.core.events.events.ToolRequest` | `core/events/src/main/kotlin/com/correx/core/events/events/ToolRequest.kt` |
|
|
| `:core:tools` | `ToolInvocationId` | `import com.correx.core.events.types.ToolInvocationId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
|
|
| `:core:tools` | `ToolResult` | `import com.correx.core.tools.contract.ToolResult` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolResult.kt` |
|
|
| `:core:tools` | `ToolExecutor` | `import com.correx.core.tools.contract.ToolExecutor` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolExecutor.kt` |
|
|
| `:core:tools` | `FileAffectingTool` | `import com.correx.core.tools.contract.FileAffectingTool` | `core/tools/src/main/kotlin/com/correx/core/tools/contract/FileAffectingTool.kt` |
|
|
| `:core:sessions` | `SessionId` | `import com.correx.core.events.types.SessionId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
|
|
| `:core:sessions` | `StageId` | `import com.correx.core.events.types.StageId` | `core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt` |
|
|
| `infrastructure:persistence:sqlite` | `SqliteEventStore` | `import com.correx.infrastructure.persistence.sqlite.SqliteEventStore` | `infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/SqliteEventStore.kt` |
|
|
|
|
---
|
|
|
|
## task 1 — `LlamaCppInferenceProvider`
|
|
|
|
**module:** `infrastructure:inference:llama_cpp`
|
|
**location:** `infrastructure/inference/llama_cpp/`
|
|
|
|
### what it does
|
|
|
|
calls llama-server's OpenAI-compatible REST API. implements `InferenceProvider`.
|
|
|
|
endpoints (constructed from `baseUrl = "http://127.0.0.1:10000"`):
|
|
- inference: `$baseUrl/v1/chat/completions`
|
|
- health: `$baseUrl/health`
|
|
- tokenize: `$baseUrl/tokenize`
|
|
|
|
### build deps
|
|
|
|
```groovy
|
|
implementation project(':core:inference')
|
|
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"
|
|
```
|
|
|
|
### request/response mapping
|
|
|
|
```kotlin
|
|
@Serializable
|
|
data class ChatCompletionRequest(
|
|
val model: String,
|
|
val messages: List<ChatMessage>,
|
|
val temperature: Double,
|
|
@SerialName("top_p") val topP: Double,
|
|
@SerialName("max_tokens") val maxTokens: Int,
|
|
@SerialName("stop") val stopSequences: List<String> = emptyList(),
|
|
val seed: Long? = null,
|
|
val stream: Boolean = false,
|
|
)
|
|
|
|
@Serializable
|
|
data class ChatMessage(val role: String, val content: String)
|
|
|
|
@Serializable
|
|
data class ChatCompletionResponse(
|
|
val id: String,
|
|
val choices: List<Choice>,
|
|
val usage: Usage,
|
|
)
|
|
|
|
@Serializable
|
|
data class Choice(
|
|
val message: ChatMessage,
|
|
@SerialName("finish_reason") val finishReason: String,
|
|
)
|
|
|
|
@Serializable
|
|
data class Usage(
|
|
@SerialName("prompt_tokens") val promptTokens: Int,
|
|
@SerialName("completion_tokens") val completionTokens: Int,
|
|
@SerialName("total_tokens") val totalTokens: Int,
|
|
)
|
|
```
|
|
|
|
### context pack → messages mapping
|
|
|
|
- L0 (system) → `role = "system"`
|
|
- L1 (task) → `role = "user"`
|
|
- L2 (history/tools) → `role = "assistant"` or `role = "user"` depending on `sourceType`
|
|
- if `ContextPack` is empty → single `user` message with empty string (do not crash)
|
|
|
|
### `LlamaCppInferenceProvider`
|
|
|
|
```kotlin
|
|
class LlamaCppInferenceProvider(
|
|
private val modelId: String,
|
|
private val baseUrl: String = "http://127.0.0.1:10000",
|
|
private val httpClient: HttpClient = defaultHttpClient(),
|
|
) : InferenceProvider {
|
|
override val id: ProviderId = ProviderId("llama-cpp:$modelId")
|
|
override val tokenizer: Tokenizer = LlamaCppTokenizer(baseUrl, httpClient)
|
|
|
|
override suspend fun infer(request: InferenceRequest): InferenceResponse
|
|
override fun healthCheck(): ProviderHealth
|
|
override fun capabilities(): Set<CapabilityScore>
|
|
}
|
|
```
|
|
|
|
### `healthCheck()`
|
|
|
|
GET `$baseUrl/health`. 200 → `ProviderHealth.Healthy`, otherwise `ProviderHealth.Unhealthy`.
|
|
|
|
### `capabilities()`
|
|
|
|
hardcoded, pattern-matched on `modelId`:
|
|
- contains "coder" → `ModelCapability.CODING` score 0.9
|
|
- contains "r1" or "deepseek" → `ModelCapability.REASONING` score 0.9
|
|
- default → `ModelCapability.GENERAL` score 0.7
|
|
|
|
### constraints
|
|
- `stream = false` always
|
|
- do NOT implement retry logic — `RetryCoordinator`'s responsibility
|
|
- do NOT catch `CancellationException` — let it propagate
|
|
- throw `InferenceProviderException` on non-2xx HTTP responses
|
|
- `latencyMs` = wall clock time of the HTTP call
|
|
|
|
---
|
|
|
|
## task 2 — `LlamaCppTokenizer`
|
|
|
|
**module:** `infrastructure:inference:llama_cpp`
|
|
|
|
```kotlin
|
|
class LlamaCppTokenizer(
|
|
private val baseUrl: String,
|
|
private val httpClient: HttpClient,
|
|
) : Tokenizer {
|
|
override fun tokenize(text: String): List<Int>
|
|
override fun count(text: String): Int = tokenize(text).size
|
|
}
|
|
```
|
|
|
|
POST `$baseUrl/tokenize` with `{"content": text}`, parse `{"tokens": [...]}`.
|
|
|
|
fallback: if endpoint unavailable → approximate `text.length / 4`, log warning, do NOT throw.
|
|
|
|
---
|
|
|
|
## task 3 — `ModelManager` + `ManagedInferenceProvider`
|
|
|
|
**module:** `infrastructure:inference:llama_cpp`
|
|
|
|
manages llama-server process lifecycle. single model at a time, enforced by `Mutex`.
|
|
|
|
### residency modes
|
|
|
|
```kotlin
|
|
enum class ResidencyMode {
|
|
PERSISTENT, // never unload
|
|
DYNAMIC, // unload after idle timeout
|
|
EPHEMERAL, // unload immediately after infer completes
|
|
}
|
|
```
|
|
|
|
### `ModelDescriptor`
|
|
|
|
```kotlin
|
|
data class ModelDescriptor(
|
|
val modelId: String,
|
|
val modelPath: String,
|
|
val residencyMode: ResidencyMode,
|
|
val idleTimeoutMs: Long = 60_000L,
|
|
val contextSize: Int = 8192,
|
|
)
|
|
```
|
|
|
|
### `ModelManager` interface
|
|
|
|
```kotlin
|
|
interface ModelManager {
|
|
suspend fun load(descriptor: ModelDescriptor): InferenceProvider
|
|
suspend fun unload(modelId: String)
|
|
fun currentModel(): ModelDescriptor?
|
|
fun healthCheck(): ProviderHealth
|
|
}
|
|
```
|
|
|
|
### `DefaultModelManager`
|
|
|
|
model swap via **process restart** — kill existing llama-server, spawn new with `--model` flag.
|
|
|
|
```kotlin
|
|
class DefaultModelManager(
|
|
private val llamaServerBin: String = "llama-server",
|
|
private val host: String = "127.0.0.1",
|
|
private val port: Int = 10000,
|
|
private val healthTimeoutMs: Long = 30_000L,
|
|
private val eventStore: EventStore,
|
|
) : ModelManager
|
|
```
|
|
|
|
**`load()` flow — event appended AFTER committed state:**
|
|
1. acquire `Mutex`
|
|
2. if `currentDescriptor?.modelId == descriptor.modelId` → no-op, return existing provider
|
|
3. if process running: `process.destroyForcibly()`, wait for exit, clear state, append `ModelUnloadedEvent`
|
|
4. spawn: `ProcessBuilder(llamaServerBin, "--model", descriptor.modelPath, "--ctx-size", descriptor.contextSize.toString(), "--host", host, "--port", port.toString())`
|
|
5. redirect stdout/stderr to `~/.local/share/correx/logs/llama-server.log` — create dir if absent
|
|
6. poll GET `http://$host:$port/health` until 200 — if `healthTimeoutMs` exceeded: throw `ModelLoadException` (no event emitted on failure)
|
|
7. set `currentProcess`, `currentDescriptor`
|
|
8. append `ModelLoadedEvent` ← only after process is healthy and state committed
|
|
9. release `Mutex`, return `ManagedInferenceProvider(delegate, this, descriptor)`
|
|
|
|
**`unload()` flow — event appended AFTER committed state:**
|
|
1. acquire `Mutex`
|
|
2. `process.destroyForcibly()`, wait for exit
|
|
3. clear `currentProcess`, `currentDescriptor`
|
|
4. append `ModelUnloadedEvent` ← only after process is dead and state cleared
|
|
5. release `Mutex`
|
|
|
|
### `ManagedInferenceProvider`
|
|
|
|
wraps `LlamaCppInferenceProvider`, notifies manager for residency policy:
|
|
|
|
```kotlin
|
|
class ManagedInferenceProvider(
|
|
private val delegate: InferenceProvider,
|
|
private val manager: DefaultModelManager,
|
|
private val descriptor: ModelDescriptor,
|
|
) : InferenceProvider by delegate {
|
|
|
|
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
|
manager.touch(descriptor.modelId) // reset DYNAMIC idle timer
|
|
val response = delegate.infer(request)
|
|
manager.onInferenceCompleted(descriptor) // trigger EPHEMERAL unload if applicable
|
|
return response
|
|
}
|
|
}
|
|
```
|
|
|
|
`DefaultModelManager` internal methods (not on `ModelManager` interface):
|
|
- `touch(modelId)` — resets DYNAMIC idle timer coroutine
|
|
- `onInferenceCompleted(descriptor)` — calls `unload()` immediately if `EPHEMERAL`, no-op otherwise
|
|
|
|
**DYNAMIC timer behaviour:**
|
|
- timer coroutine starts on first `touch()` after load
|
|
- each `touch()` cancels and restarts the timer
|
|
- on timeout: calls `unload()`
|
|
|
|
### constraints
|
|
- do NOT manage GPU memory directly — llama-server handles that
|
|
- do NOT emit events before process state is committed — event log must reflect truth, not intent
|
|
- `ModelLoadException` thrown on health timeout — no event emitted in this case
|
|
- spawned process log dir created on first use if absent
|
|
|
|
---
|
|
|
|
## task 4 — `ToolRegistry`
|
|
|
|
**module:** `infrastructure:tools`
|
|
|
|
immutable after construction:
|
|
|
|
```kotlin
|
|
class ToolRegistry private constructor(
|
|
private val tools: Map<String, Tool>,
|
|
) {
|
|
fun resolve(name: String): Tool? = tools[name]
|
|
fun all(): List<Tool> = tools.values.toList()
|
|
|
|
companion object {
|
|
fun build(vararg tools: Tool): ToolRegistry =
|
|
ToolRegistry(tools.associateBy { it.name })
|
|
|
|
fun build(tools: List<Tool>): ToolRegistry =
|
|
ToolRegistry(tools.associateBy { it.name })
|
|
}
|
|
}
|
|
```
|
|
|
|
no `register()` method — `private constructor` enforces bootstrap-only population.
|
|
|
|
---
|
|
|
|
## task 5 — `SandboxedToolExecutor`
|
|
|
|
**module:** `infrastructure:tools`
|
|
|
|
```kotlin
|
|
class SandboxedToolExecutor(
|
|
private val delegate: ToolExecutor,
|
|
private val registry: ToolRegistry,
|
|
private val approvalEngine: ApprovalEngine,
|
|
private val eventStore: EventStore,
|
|
private val workDir: Path = Path("/tmp/correx-sandbox"),
|
|
) : ToolExecutor {
|
|
override suspend fun execute(request: ToolRequest): ToolResult
|
|
}
|
|
```
|
|
|
|
execution flow:
|
|
1. resolve tool from `registry` by `request.toolName` — if not found: return `ToolResult.Failure(recoverable = false)`
|
|
2. check approval via `tool.tier`:
|
|
```kotlin
|
|
when (tool.tier) {
|
|
Tier.T0, Tier.T1 -> { /* proceed */ }
|
|
Tier.T2, Tier.T3 -> { /* evaluate approval */ }
|
|
}
|
|
```
|
|
if not approved: emit `ToolExecutionRejectedEvent`, return `ToolResult.Failure(recoverable = false)`
|
|
3. emit `ToolExecutionStartedEvent`
|
|
4. create working dir: `/tmp/correx-sandbox/{sessionId}/{invocationId}/`
|
|
5. if tool implements `FileAffectingTool`: call `tool.affectedPaths(request)`, copy each to `{workDir}/{filename}.bak` via `Files.copy`
|
|
6. if backup fails: return `ToolResult.Failure` before delegating — do NOT proceed
|
|
7. delegate to underlying `ToolExecutor`
|
|
8. on `ToolResult.Success`: `Files.move` result to original path, delete `.bak` files, emit `ToolExecutionCompletedEvent`
|
|
9. on `ToolResult.Failure`: restore `.bak` files via `Files.move`, delete working dir, emit `ToolExecutionFailedEvent`
|
|
|
|
### constraints
|
|
- working dir is fresh per invocation — no shared state
|
|
- `.bak` files always cleaned up — no leaks regardless of outcome
|
|
- do NOT use `File.deleteOnExit()` — explicit cleanup only
|
|
- backup/restore uses `Files.copy` + `Files.move` — not shell commands
|
|
|
|
---
|
|
|
|
## task 6 — `ShellTool`
|
|
|
|
**module:** `infrastructure:tools:shell`
|
|
|
|
uses `argv: List<String>` — no shell string parsing, no shell invocation.
|
|
|
|
```kotlin
|
|
class ShellTool(
|
|
private val allowedExecutables: Set<String> = emptySet(),
|
|
private val timeoutMs: Long = 30_000L,
|
|
) : Tool, ToolExecutor {
|
|
override val name: String = "shell"
|
|
override val tier: Tier = Tier.T2
|
|
override val requiredCapabilities: Set<ToolCapability> =
|
|
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
|
|
|
|
override fun validateRequest(request: ToolRequest): ValidationResult
|
|
override suspend fun execute(request: ToolRequest): ToolResult
|
|
}
|
|
```
|
|
|
|
`execute()`:
|
|
- extract `argv: List<String>` from `request.parameters["argv"]`
|
|
- first element is the executable — validate against `allowedExecutables`
|
|
- if not in allowlist: return `ToolResult.Failure(recoverable = false)` immediately
|
|
- spawn: `ProcessBuilder(argv).apply { environment().clear() }`
|
|
- capture stdout + stderr separately
|
|
- enforce `timeoutMs` — `process.destroyForcibly()` on timeout, return `ToolResult.Failure`
|
|
- exit code 0 → `ToolResult.Success(output = stdout)`
|
|
- non-zero → `ToolResult.Failure(reason = stderr, recoverable = false)`
|
|
|
|
### constraints
|
|
- do NOT invoke `bash -c` or any shell — `ProcessBuilder(argv)` directly
|
|
- do NOT use `Runtime.exec()`
|
|
- do NOT inherit parent process environment — `environment().clear()` explicitly
|
|
- stdout and stderr captured, never forwarded to parent stdout
|
|
|
|
---
|
|
|
|
## task 7a — `FileReadTool`
|
|
|
|
**file:** `infrastructure/tools/filesystem/FileReadTool.kt`
|
|
**module:** `infrastructure:tools:filesystem`
|
|
|
|
```kotlin
|
|
class FileReadTool(
|
|
private val allowedPaths: Set<Path> = emptySet(),
|
|
) : Tool {
|
|
override val name: String = "file_read"
|
|
override val tier: Tier = Tier.T1
|
|
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
|
|
|
|
override fun validateRequest(request: ToolRequest): ValidationResult
|
|
}
|
|
```
|
|
|
|
also implements `ToolExecutor` directly.
|
|
|
|
supported operations (from `request.parameters["operation"]`):
|
|
- `read` — read file contents, return as `output`
|
|
- `list` — list directory contents at `path`
|
|
- `exists` — return `"true"` or `"false"`
|
|
|
|
does NOT implement `FileAffectingTool` — read operations require no backup.
|
|
|
|
path validation before ANY operation:
|
|
1. extract `path` from `request.parameters["path"]`
|
|
2. resolve to absolute via `toRealPath()`
|
|
3. check resolved path starts with at least one `allowedPaths` root
|
|
4. if not: return `ToolResult.Failure(recoverable = false)`
|
|
|
|
### constraints
|
|
- `allowedPaths` empty = deny ALL — fail-secure default
|
|
- `toRealPath()` resolves symlinks — re-validate after resolution
|
|
- do NOT follow symlinks outside allowed paths
|
|
- T1 — goes through `SandboxedToolExecutor` approval check but auto-approved
|
|
|
|
---
|
|
|
|
## task 7b — `FileWriteTool`
|
|
|
|
**file:** `infrastructure/tools/filesystem/FileWriteTool.kt`
|
|
**module:** `infrastructure:tools:filesystem`
|
|
|
|
```kotlin
|
|
class FileWriteTool(
|
|
private val allowedPaths: Set<Path> = emptySet(),
|
|
) : Tool, FileAffectingTool {
|
|
override val name: String = "file_write"
|
|
override val tier: Tier = Tier.T2
|
|
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
|
|
|
|
override fun affectedPaths(request: ToolRequest): Set<Path>
|
|
override fun validateRequest(request: ToolRequest): ValidationResult
|
|
}
|
|
```
|
|
|
|
also implements `ToolExecutor`.
|
|
|
|
supported operations:
|
|
- `write` — create or fully overwrite file at `path` with `content` parameter
|
|
- `delete` — delete file at `path`
|
|
|
|
`affectedPaths()` — extract `path` from request, resolve absolute, return as singleton set.
|
|
|
|
path validation — same as `FileReadTool` but applied to write targets.
|
|
|
|
### constraints
|
|
- `allowedPaths` empty = deny ALL
|
|
- `toRealPath()` on parent dir for new files — target file may not exist yet, validate parent
|
|
- T2 — requires approval via `SandboxedToolExecutor`
|
|
- backup/restore handled by `SandboxedToolExecutor` via `FileAffectingTool` — do NOT implement backup here
|
|
|
|
---
|
|
|
|
## task 7c — `FileEditTool`
|
|
|
|
**file:** `infrastructure/tools/filesystem/FileEditTool.kt`
|
|
**module:** `infrastructure:tools:filesystem`
|
|
|
|
```kotlin
|
|
class FileEditTool(
|
|
private val allowedPaths: Set<Path> = emptySet(),
|
|
) : Tool, FileAffectingTool {
|
|
override val name: String = "file_edit"
|
|
override val tier: Tier = Tier.T3
|
|
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
|
|
|
|
override fun affectedPaths(request: ToolRequest): Set<Path>
|
|
override fun validateRequest(request: ToolRequest): ValidationResult
|
|
}
|
|
```
|
|
|
|
also implements `ToolExecutor`.
|
|
|
|
supported operations (from `request.parameters["operation"]`):
|
|
- `append` — append `content` to end of existing file
|
|
- `replace` — find `target` string in file, replace with `replacement` — fails if `target` not found or matches multiple times
|
|
- `patch` — apply a unified diff from `patch` parameter via `ProcessBuilder("patch", "-p0")` — T3 because patch application is the hardest to verify
|
|
|
|
`affectedPaths()` — same as `FileWriteTool`, returns target path as singleton.
|
|
|
|
path validation — same pattern, file must exist for all operations.
|
|
|
|
### constraints
|
|
- `allowedPaths` empty = deny ALL
|
|
- `replace` must match exactly once — if zero or multiple matches: `ToolResult.Failure(recoverable = false)`
|
|
- `patch` invokes external `patch` binary via `ProcessBuilder` — do NOT use shell
|
|
- T3 — highest approval tier for file tools
|
|
- backup/restore handled by `SandboxedToolExecutor` — always backs up before any edit
|
|
|
|
---
|
|
|
|
## task 8 — integration wiring
|
|
|
|
**module:** `infrastructure`
|
|
|
|
```kotlin
|
|
object InfrastructureModule {
|
|
fun createEventStore(dbPath: String): EventStore =
|
|
SqliteEventStore(DriverManager.getConnection("jdbc:sqlite:$dbPath"))
|
|
|
|
fun createModelManager(eventStore: EventStore): ModelManager =
|
|
DefaultModelManager(eventStore = eventStore)
|
|
|
|
fun createToolRegistry(config: ToolConfig): ToolRegistry =
|
|
ToolRegistry.build(buildList {
|
|
if (config.shell.enabled) add(ShellTool(config.shell.allowedExecutables))
|
|
if (config.fileRead.enabled) add(FileReadTool(config.fileRead.allowedPaths))
|
|
if (config.fileWrite.enabled) add(FileWriteTool(config.fileWrite.allowedPaths))
|
|
if (config.fileEdit.enabled) add(FileEditTool(config.fileEdit.allowedPaths))
|
|
})
|
|
|
|
fun createToolExecutor(
|
|
registry: ToolRegistry,
|
|
approvalEngine: ApprovalEngine,
|
|
eventStore: EventStore,
|
|
): ToolExecutor = SandboxedToolExecutor(
|
|
delegate = DispatchingToolExecutor(registry),
|
|
registry = registry,
|
|
approvalEngine = approvalEngine,
|
|
eventStore = eventStore,
|
|
)
|
|
}
|
|
```
|
|
|
|
`DispatchingToolExecutor` — resolves tool from registry by `request.toolName`, delegates. returns `ToolResult.Failure` if not found.
|
|
|
|
`ToolConfig` stub until epic 12:
|
|
```kotlin
|
|
data class ToolConfig(
|
|
val shell: ShellConfig = ShellConfig(),
|
|
val fileRead: FileReadConfig = FileReadConfig(),
|
|
val fileWrite: FileWriteConfig = FileWriteConfig(),
|
|
val fileEdit: FileEditConfig = FileEditConfig(),
|
|
)
|
|
data class FileReadConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
|
|
data class FileWriteConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
|
|
data class FileEditConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
|
|
data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set<String> = emptySet())
|
|
```
|
|
|
|
---
|
|
|
|
## minimum test targets
|
|
|
|
### `LlamaCppInferenceProviderTest`
|
|
- request maps `GenerationConfig` fields correctly to `ChatCompletionRequest`
|
|
- usage mapped to `TokenUsage` correctly
|
|
- non-2xx response → `InferenceProviderException` thrown
|
|
- `healthCheck()` returns `Healthy` on 200, `Unhealthy` otherwise
|
|
|
|
### `DefaultModelManagerTest`
|
|
- `load()` same `modelId` twice → no-op, no process restart
|
|
- `load()` different `modelId` → kills current process, starts new
|
|
- health check timeout → `ModelLoadException`, no `ModelLoadedEvent` emitted
|
|
- `ModelLoadedEvent` emitted only after health check passes
|
|
- `ModelUnloadedEvent` emitted only after process is dead
|
|
|
|
### `ManagedInferenceProviderTest`
|
|
- `EPHEMERAL`: `unload()` called after `infer()` completes
|
|
- `DYNAMIC`: idle timer resets on each `infer()` call
|
|
- `PERSISTENT`: `unload()` never called automatically
|
|
|
|
### `ShellToolTest`
|
|
- empty `allowedExecutables` → all commands denied
|
|
- executable not in allowlist → `Failure` before process spawn
|
|
- timeout → process killed, `Failure` returned
|
|
- non-zero exit → `Failure` with stderr as reason
|
|
- environment is cleared — no parent env inheritance
|
|
|
|
### `FilesystemToolTest`
|
|
- empty `allowedPaths` → all paths denied
|
|
- path traversal `../` → blocked after `toRealPath()` resolution
|
|
- symlink escape → blocked after re-validation
|
|
- `affectedPaths()` returns empty set for `read`/`list`/`exists`
|
|
|
|
### `SandboxedToolExecutorTest`
|
|
- approval rejected → `ToolExecutionRejectedEvent` emitted, delegate not called
|
|
- delegate returns `Failure` → `.bak` restored, working dir deleted
|
|
- backup creation fails → `Failure` before delegate call
|
|
- `.bak` files cleaned up on success
|
|
|
|
---
|
|
|
|
## explicitly out of scope
|
|
|
|
- postgres
|
|
- ollama / vllm
|
|
- docker tools
|
|
- network tools
|
|
- telemetry exporters
|
|
- process namespace isolation
|
|
- CLI / API wiring (epic 12)
|
|
- config file loading (epic 12) |