fix(kernel,tools,validation): unblock role_pipeline end-to-end + QA audit
Live QA audit of role_pipeline against a sample repo surfaced and fixed a chain of defects that prevented any tool+artifact workflow from running end-to-end. The pipeline now completes cleanly with a real, validated, reviewed file change. - F-008 workflow: propagate stage token_budget -> generationConfig.maxTokens (TomlWorkflowLoader) so large stages stop truncating at the 2048 default. - F-009 tools/kernel: file_read missing-file is recoverable; recoverable tool errors feed back into the loop instead of aborting the stage. - F-010/F-018 kernel: reject premature stage_complete and nudge the model to emit the owed artifact / invoke a write tool (bounded by MAX_TOOL_ROUNDS). - F-011 schemas: list-typed artifact fields string -> array (analysis/design/ impl_plan) to match model output. - F-012 kernel: strip an outer markdown code fence from LLM artifacts. - F-013 validation: payload-validation failures are retryable; structural failures stay terminal (wires the invariant-#7 reject+retry contract). - F-014 tools: file_edit schema matches its real params; edits emit a diff. - F-015 kernel: record tool-execution events + materialise a real file_written artifact from the on-disk write; gate validation so a no-write stage cannot be rubber-stamped (no more false-success runs). - F-016 server: raise stageTimeoutMs 60s -> 180s for slow local models. - F-019 artifacts/kernel: file_written artifact carries the unified diff so the reviewer can verify the change (shared DiffUtil; file_write emits a diff too). - F-020 tools: model-correctable file_edit failures are recoverable + steer toward replace/file_write over the fragile patch op. Full findings register (F-001..F-021, incl. open F-021 resume rehydrate bug) in qa/audit-report-2026-06-08.md. All module tests green.
This commit is contained in:
+214
-12
@@ -12,7 +12,9 @@ import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.artifacts.ArtifactState
|
||||
import com.correx.core.artifacts.kind.JsonSchema
|
||||
import com.correx.core.artifacts.kind.FileWrittenArtifact
|
||||
import com.correx.core.artifacts.kind.ProcessResultArtifact
|
||||
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.context.builder.ContextPackBuilder
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
@@ -40,9 +42,13 @@ import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.RiskAssessedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.ToolReceipt
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.risk.RiskSummary
|
||||
@@ -87,6 +93,7 @@ import com.correx.core.risk.RiskContext
|
||||
import com.correx.core.risk.toApprovalTier
|
||||
import com.correx.core.sessions.Session
|
||||
import com.correx.core.tools.compression.ToolOutputContext
|
||||
import com.correx.core.tools.contract.FileAffectingTool
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
@@ -114,6 +121,8 @@ import com.correx.core.journal.DecisionJournalRenderer
|
||||
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||
import com.correx.core.kernel.orchestration.subagent.SubagentRunner
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
@@ -121,6 +130,7 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
private const val MAX_TOOL_ROUNDS = 10
|
||||
private const val STAGE_COMPLETE_TOOL = "stage_complete"
|
||||
private const val OUTPUT_SUMMARY_LIMIT = 500
|
||||
private const val REPO_MAP_INJECT_TOP_K = 30
|
||||
|
||||
@SuppressWarnings(
|
||||
@@ -366,16 +376,74 @@ abstract class SessionOrchestrator(
|
||||
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
|
||||
)
|
||||
var toolRounds = 0
|
||||
|
||||
val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
|
||||
fun owesFileWrite(): Boolean = fileWrittenSlots.isNotEmpty() &&
|
||||
fileWrittenSlots.any { artifactContentCache["${sessionId.value}:${it.name.value}"].isNullOrBlank() }
|
||||
|
||||
// Append a corrective tool-result and re-run inference (bounded by MAX_TOOL_ROUNDS).
|
||||
// Returns the new result rather than mutating inferenceResult, to preserve smart casts.
|
||||
suspend fun pushBack(nudge: String): InferenceResult {
|
||||
accumulatedEntries = accumulatedEntries + ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
sourceType = "toolResult",
|
||||
sourceId = UUID.randomUUID().toString(),
|
||||
content = nudge,
|
||||
tokenEstimate = estimateTokens(nudge),
|
||||
role = EntryRole.TOOL,
|
||||
)
|
||||
currentContext = contextPackBuilder.build(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
entries = accumulatedEntries,
|
||||
budget = TokenBudget(limit = stageConfig.tokenBudget),
|
||||
)
|
||||
emitContextTruncationIfNeeded(sessionId, stageId, currentContext)
|
||||
toolRounds++
|
||||
return runInference(
|
||||
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
|
||||
)
|
||||
}
|
||||
|
||||
val writeNudge = "ERROR: you described the change but did not apply it. You MUST call the " +
|
||||
"file_edit or file_write tool to write the change to the file before finishing — do " +
|
||||
"not output the file contents as a message."
|
||||
|
||||
while (
|
||||
inferenceResult is InferenceResult.Success &&
|
||||
inferenceResult.response.finishReason is FinishReason.ToolCall &&
|
||||
toolRounds < MAX_TOOL_ROUNDS
|
||||
toolRounds < MAX_TOOL_ROUNDS &&
|
||||
(inferenceResult.response.finishReason is FinishReason.ToolCall || owesFileWrite())
|
||||
) {
|
||||
// Content turn (no tool call) but the stage still owes a file_written artifact: the
|
||||
// model emitted the change as prose instead of invoking the write tool. Nudge it to
|
||||
// actually write (F-018).
|
||||
if (inferenceResult.response.finishReason !is FinishReason.ToolCall) {
|
||||
inferenceResult = pushBack(writeNudge)
|
||||
continue
|
||||
}
|
||||
// stage_complete is a meta-tool: the LLM calls it to signal the stage's goal is met.
|
||||
// Skip tool dispatch — the loop exits and normal validation/transition follows
|
||||
// (emitToolArtifacts + verifyProduces run on the success path after the loop).
|
||||
if (inferenceResult.response.toolCalls.any { it.function.name == STAGE_COMPLETE_TOOL }) {
|
||||
break
|
||||
// Premature-completion guard: don't accept stage_complete while the stage still
|
||||
// owes an artifact. For an LLM-emitted slot, nudge to emit the JSON (F-010); for a
|
||||
// file_written slot, nudge to invoke the write tool (F-018). Otherwise complete.
|
||||
val owedLlm = llmEmittedSlots.firstOrNull()?.takeIf {
|
||||
inferenceResult.response.text.isBlank() &&
|
||||
artifactContentCache["${sessionId.value}:${it.name.value}"].isNullOrBlank()
|
||||
}
|
||||
when {
|
||||
owedLlm != null -> inferenceResult = pushBack(
|
||||
"ERROR: stage_complete is not allowed yet — you have not emitted the " +
|
||||
"required '${owedLlm.name.value}' artifact. Respond with the artifact as " +
|
||||
"a single JSON message (no tool call) matching the provided schema.",
|
||||
)
|
||||
owesFileWrite() -> inferenceResult = pushBack(writeNudge)
|
||||
else -> break
|
||||
}
|
||||
continue
|
||||
}
|
||||
val toolEntries = dispatchToolCalls(sessionId, stageId,
|
||||
inferenceResult.response.toolCalls, stageConfig, effectives)
|
||||
@@ -387,14 +455,9 @@ abstract class SessionOrchestrator(
|
||||
retryable = false,
|
||||
)
|
||||
}
|
||||
val errorEntry = toolEntries.firstOrNull { it.content.startsWith("ERROR:") }
|
||||
if (errorEntry != null) {
|
||||
emitProcessResultEvents(sessionId, stageId, stageConfig)
|
||||
return StageExecutionResult.Failure(
|
||||
errorEntry.content.removePrefix("ERROR: "),
|
||||
retryable = true,
|
||||
)
|
||||
}
|
||||
// Recoverable (ERROR:) tool failures are NOT terminal — they flow back into the
|
||||
// loop as tool-result context so the model can see the error and adapt (bounded by
|
||||
// MAX_TOOL_ROUNDS). Only FATAL: failures (handled above) abort the stage.
|
||||
accumulatedEntries = accumulatedEntries + toolEntries
|
||||
currentContext = contextPackBuilder.build(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
@@ -459,6 +522,21 @@ abstract class SessionOrchestrator(
|
||||
): String = "[SessionOrchestrator] stage=${stageId.value}: " +
|
||||
"failed to load prompt '$path': ${throwable.message}"
|
||||
|
||||
/**
|
||||
* Strip an outer markdown code fence (```` ```json `` / ``` ``` ````) when the whole response
|
||||
* is a single fenced block. Conservative: only acts when the trimmed text both starts and ends
|
||||
* with a fence, so prose responses that merely contain an inline block are left untouched.
|
||||
*/
|
||||
private fun stripCodeFence(text: String): String {
|
||||
val trimmed = text.trim()
|
||||
if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return text
|
||||
val withoutClose = trimmed.removeSuffix("```").trimEnd()
|
||||
val firstNewline = withoutClose.indexOf('\n')
|
||||
if (firstNewline < 0) return text
|
||||
// Drop the opening fence line (``` optionally followed by a language tag).
|
||||
return withoutClose.substring(firstNewline + 1).trim()
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private suspend fun dispatchToolCalls(
|
||||
sessionId: SessionId,
|
||||
@@ -469,6 +547,7 @@ abstract class SessionOrchestrator(
|
||||
): List<ContextEntry> {
|
||||
val executor = effectives.executor ?: return emptyList()
|
||||
val processResultSlots = stageConfig.produces.filter { it.kind.id == "process_result" }
|
||||
val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" }
|
||||
return toolCalls.flatMap { toolCall ->
|
||||
val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
||||
val parameters = runCatching {
|
||||
@@ -730,6 +809,16 @@ abstract class SessionOrchestrator(
|
||||
)
|
||||
}
|
||||
|
||||
// Record the tool execution outcome as events (invariant #5: every side effect is
|
||||
// captured; silent execution is not allowed). For file-mutating tools, materialise a
|
||||
// real file_written artifact from the actual on-disk result — so a stage that did NOT
|
||||
// write cannot have its file_written slot rubber-stamped as validated (F-015), and the
|
||||
// artifact carries the diff as evidence of the change.
|
||||
recordToolExecution(
|
||||
sessionId, stageId, toolCall, invocationId, tier, result,
|
||||
tool as? FileAffectingTool, request, fileWrittenSlots,
|
||||
)
|
||||
|
||||
val sourceId = toolCall.id ?: invocationId.value
|
||||
val assistantEntry = ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
@@ -942,12 +1031,120 @@ abstract class SessionOrchestrator(
|
||||
stageConfig.produces
|
||||
.filter { !it.kind.llmEmitted }
|
||||
.forEach { slot ->
|
||||
// A file_written slot must be backed by real content materialised from an actual
|
||||
// successful write (see recordToolExecution). If none happened, do NOT fabricate a
|
||||
// validated artifact (F-015) — leave the slot unproduced so verifyProduces fails the
|
||||
// stage and it retries, rather than reporting a green run that changed nothing.
|
||||
if (slot.kind.id == "file_written" &&
|
||||
artifactContentCache["${sessionId.value}:${slot.name.value}"] == null
|
||||
) {
|
||||
return@forEach
|
||||
}
|
||||
emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1))
|
||||
emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId))
|
||||
emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun recordToolExecution(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
toolCall: ToolCallRequest,
|
||||
invocationId: ToolInvocationId,
|
||||
tier: Tier,
|
||||
result: ToolResult,
|
||||
fileTool: FileAffectingTool?,
|
||||
request: ToolRequest,
|
||||
fileWrittenSlots: List<TypedArtifactSlot>,
|
||||
) {
|
||||
when (result) {
|
||||
is ToolResult.Failure -> emit(
|
||||
sessionId,
|
||||
ToolExecutionFailedEvent(invocationId, sessionId, toolCall.function.name, result.reason),
|
||||
)
|
||||
|
||||
is ToolResult.Success -> {
|
||||
val diff = result.metadata["diff"]?.takeIf { it.isNotBlank() }
|
||||
val affected = fileTool?.affectedPaths(request).orEmpty()
|
||||
emit(
|
||||
sessionId,
|
||||
ToolExecutionCompletedEvent(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
toolName = toolCall.function.name,
|
||||
receipt = ToolReceipt(
|
||||
invocationId = invocationId,
|
||||
toolName = toolCall.function.name,
|
||||
exitCode = result.exitCode,
|
||||
outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
|
||||
affectedEntities = affected.map { it.toString() },
|
||||
durationMs = 0,
|
||||
tier = tier,
|
||||
timestamp = Clock.System.now(),
|
||||
diff = diff,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (affected.isNotEmpty()) {
|
||||
materializeFileWritten(sessionId, stageId, request, affected, fileWrittenSlots, diff)
|
||||
affected.forEach { p ->
|
||||
emit(
|
||||
sessionId,
|
||||
FileWrittenEvent(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
path = p.toString(),
|
||||
preExisted = true,
|
||||
timestampMs = Clock.System.now().toEpochMilliseconds(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise a [FileWrittenArtifact] from the actual on-disk file after a successful mutation,
|
||||
* store it as the stage's file_written slot content, and record [ArtifactContentStoredEvent].
|
||||
* This ties the file_written artifact to a real write so validation can no longer be faked.
|
||||
*/
|
||||
private suspend fun materializeFileWritten(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
request: ToolRequest,
|
||||
affected: Set<java.nio.file.Path>,
|
||||
fileWrittenSlots: List<TypedArtifactSlot>,
|
||||
diff: String?,
|
||||
) {
|
||||
if (fileWrittenSlots.isEmpty()) return
|
||||
val relPath = request.parameters["path"] as? String ?: return
|
||||
val onDisk = affected.firstOrNull() ?: Paths.get(relPath)
|
||||
val bytes = runCatching { Files.readAllBytes(onDisk) }.getOrNull() ?: return
|
||||
val contentHash = artifactStore.put(bytes)
|
||||
val artifact = FileWrittenArtifact(
|
||||
path = relPath,
|
||||
contentHash = contentHash.value,
|
||||
size = bytes.size.toLong(),
|
||||
mode = "0644",
|
||||
diff = diff,
|
||||
)
|
||||
val json = Json.encodeToString(FileWrittenArtifact.serializer(), artifact)
|
||||
val storedHash = artifactStore.put(json.toByteArray())
|
||||
fileWrittenSlots.forEach { slot ->
|
||||
artifactContentCache["${sessionId.value}:${slot.name.value}"] = json
|
||||
emit(
|
||||
sessionId,
|
||||
ArtifactContentStoredEvent(
|
||||
artifactId = slot.name,
|
||||
contentHash = storedHash,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun emitLlmArtifacts(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
@@ -1075,7 +1272,12 @@ abstract class SessionOrchestrator(
|
||||
if (isCancelled(sessionId)) return InferenceResult.Cancelled
|
||||
|
||||
return try {
|
||||
val response = withTimeout(timeoutMs) { provider.infer(request) }
|
||||
val rawResponse = withTimeout(timeoutMs) { provider.infer(request) }
|
||||
// Models frequently wrap a JSON artifact in a ```json … ``` markdown fence despite
|
||||
// being told not to. The fence makes the artifact unparseable → validation fails.
|
||||
// Strip an outer fence wrapper so the stored artifact, its CAS hash, the content
|
||||
// cache, and validation all see the same clean payload.
|
||||
val response = rawResponse.copy(text = stripCodeFence(rawResponse.text))
|
||||
log.debug(
|
||||
"[Orchestrator] inference done session={} stage={} tokens={} latencyMs={}",
|
||||
sessionId.value, stageId.value, response.tokensUsed, response.latencyMs,
|
||||
|
||||
Reference in New Issue
Block a user