From e32bd121e583dad33129b2132aaae20eb1e3d7fc Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 26 May 2026 17:33:07 +0400 Subject: [PATCH] fix: store ProcessResult artifact for every shell tool outcome, not only FATAL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move ProcessResult artifact content storage into dispatchToolCalls so it fires for all outcomes (success, ERROR, FATAL) — the spec mandates the artifact MUST always be produced. - dispatchToolCalls now stores ProcessResult JSON in CAS + cache for every tool call result, using the current toolCall's function.arguments as the command field (not firstOrNull) - Replace storeFailedProcessResult with emitProcessResultEvents — event emission only, content already stored by dispatchToolCalls - FATAL and ERROR branches both now emit artifact lifecycle events, fixing the gap where retryable ToolResult.Failure produced no artifact --- .../orchestration/SessionOrchestrator.kt | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 0fddc6c9..755a3c48 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -239,11 +239,11 @@ abstract class SessionOrchestrator( inferenceResult.response.finishReason is FinishReason.ToolCall && toolRounds < MAX_TOOL_ROUNDS ) { - val toolEntries = dispatchToolCalls(sessionId, stageId, inferenceResult.response.toolCalls) + val toolEntries = dispatchToolCalls(sessionId, stageId, + inferenceResult.response.toolCalls, stageConfig) val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") } if (fatalEntry != null) { - storeFailedProcessResult(sessionId, stageId, stageConfig, - inferenceResult.response.toolCalls, fatalEntry) + emitProcessResultEvents(sessionId, stageId, stageConfig) return StageExecutionResult.Failure( fatalEntry.content.removePrefix("FATAL: "), retryable = false, @@ -251,6 +251,7 @@ abstract class SessionOrchestrator( } val errorEntry = toolEntries.firstOrNull { it.content.startsWith("ERROR:") } if (errorEntry != null) { + emitProcessResultEvents(sessionId, stageId, stageConfig) return StageExecutionResult.Failure( errorEntry.content.removePrefix("ERROR: "), retryable = true, @@ -299,8 +300,10 @@ abstract class SessionOrchestrator( sessionId: SessionId, stageId: StageId, toolCalls: List, + stageConfig: StageConfig, ): List { val executor = toolExecutor ?: return emptyList() + val processResultSlots = stageConfig.produces.filter { it.kind.id == "process_result" } return toolCalls.flatMap { toolCall -> val invocationId = ToolInvocationId(UUID.randomUUID().toString()) val parameters = runCatching { @@ -391,6 +394,32 @@ abstract class SessionOrchestrator( emit(sessionId, OrchestrationResumedEvent(sessionId, stageId)) } val result = executor.execute(request) + + // Store ProcessResult artifact for every shell execution outcome + for (slot in processResultSlots) { + val processResult = when (result) { + is ToolResult.Success -> ProcessResultArtifact( + status = "success", + command = toolCall.function.arguments, + exitCode = result.exitCode, + stdout = result.output, + stderr = result.metadata["stderr"] ?: "", + durationMs = 0, + ) + is ToolResult.Failure -> ProcessResultArtifact( + status = "failed", + command = toolCall.function.arguments, + exitCode = -1, + stdout = "", + stderr = result.reason, + durationMs = 0, + ) + } + val jsonContent = Json.encodeToString(ProcessResultArtifact.serializer(), processResult) + artifactStore.put(jsonContent.toByteArray()) + artifactContentCache["${sessionId.value}:${slot.name.value}"] = jsonContent + } + val sourceId = toolCall.id ?: invocationId.value val assistantEntry = ContextEntry( id = ContextEntryId(UUID.randomUUID().toString()), @@ -477,28 +506,14 @@ abstract class SessionOrchestrator( ) } - private suspend fun storeFailedProcessResult( + private suspend fun emitProcessResultEvents( sessionId: SessionId, stageId: StageId, stageConfig: StageConfig, - toolCalls: List, - fatalEntry: ContextEntry, ) { + // Emit artifact lifecycle events for process_result slots on failure branches. + // Content was already stored in CAS + cache by dispatchToolCalls for every outcome. stageConfig.produces.filter { it.kind.id == "process_result" }.forEach { slot -> - val cmd = toolCalls.firstOrNull()?.function?.arguments ?: "" - val reason = fatalEntry.content.removePrefix("FATAL: ") - val processResult = ProcessResultArtifact( - status = "failed", - command = cmd, - exitCode = -1, - stdout = "", - stderr = reason, - durationMs = 0, - ) - val jsonContent = Json.encodeToString(ProcessResultArtifact.serializer(), processResult) - artifactStore.put(jsonContent.toByteArray()) - val cacheKey = "${sessionId.value}:${slot.name.value}" - artifactContentCache[cacheKey] = jsonContent emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1)) emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId)) emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId))