fix: store ProcessResult artifact for every shell tool outcome, not only FATAL

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
This commit is contained in:
2026-05-26 17:33:07 +04:00
parent 3b24c7e91a
commit e32bd121e5
@@ -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<ToolCallRequest>,
stageConfig: StageConfig,
): List<ContextEntry> {
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<ToolCallRequest>,
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))