From 94f7ad0ee900b50474ae0d6fffd26f1613cf2e56 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 1 Jun 2026 23:23:29 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20workflow=20inference=20chain=20=E2=80=94?= =?UTF-8?q?=20surface=20llama-server=20errors,=20user-turn-last,=20bundle-?= =?UTF-8?q?relative=20prompts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LlamaCppInferenceProvider: check HTTP status and surface the real error body instead of masking it as a ChatCompletionResponse deserialization failure - PromptRenderer: render the live conversation layer (L1) last so the user turn is the final message (fixes Qwen 'No user query found' 500 when L3 recall is present) - TomlWorkflowLoader: resolve stage prompts relative to the workflow file's own dir (bundle), CWD-independent; config-dir fallback for globals - SessionOrchestrator: hard-fail a stage whose declared prompt can't resolve (was a silent user-less request) - move healthcheck prompts next to the example workflow (examples/workflows/prompts/) --- .../correx/core/inference/PromptRenderer.kt | 35 ++++++++++++------- .../orchestration/SessionOrchestrator.kt | 10 +++--- .../workflows/prompts}/healthcheck_execute.md | 0 .../workflows/prompts}/healthcheck_write.md | 0 .../llama/cpp/LlamaCppInferenceProvider.kt | 12 +++++-- .../workflow/TomlWorkflowLoader.kt | 16 ++++++--- 6 files changed, 50 insertions(+), 23 deletions(-) rename {prompts => examples/workflows/prompts}/healthcheck_execute.md (100%) rename {prompts => examples/workflows/prompts}/healthcheck_write.md (100%) diff --git a/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt b/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt index dab2275b..cbd7b767 100644 --- a/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt +++ b/core/inference/src/main/kotlin/com/correx/core/inference/PromptRenderer.kt @@ -12,6 +12,14 @@ data class ChatMessage( ) object PromptRenderer { + // L1 (live conversation, ends with the current user turn) must render last so + // the model template sees the user query as the final message. Background/memory + // layers (L2, L3, L4) are injected before it. + private val nonL0RenderOrder: Comparator = Comparator { a, b -> + val priority = { layer: ContextLayer -> if (layer == ContextLayer.L1) Int.MAX_VALUE else layer.ordinal } + priority(a) - priority(b) + } + fun render(contextPack: ContextPack): List { val sorted = contextPack.layers.entries.sortedBy { it.key.ordinal } val systemContent = sorted @@ -19,19 +27,22 @@ object PromptRenderer { .flatMap { it.value } .joinToString("\n\n") { it.content } .takeIf { it.isNotBlank() } - val conversationMessages = sorted.filter { it.key != ContextLayer.L0 }.flatMap { (_, entries) -> - entries.map { entry -> - ChatMessage( - role = when (entry.role) { - EntryRole.SYSTEM -> "system" - EntryRole.ASSISTANT -> "assistant" - EntryRole.TOOL -> "tool" - EntryRole.USER -> "user" - }, - content = entry.content, - ) + val conversationMessages = contextPack.layers.entries + .filter { it.key != ContextLayer.L0 } + .sortedWith(Comparator.comparing({ it.key }, nonL0RenderOrder)) + .flatMap { (_, entries) -> + entries.map { entry -> + ChatMessage( + role = when (entry.role) { + EntryRole.SYSTEM -> "system" + EntryRole.ASSISTANT -> "assistant" + EntryRole.TOOL -> "tool" + EntryRole.USER -> "user" + }, + content = entry.content, + ) + } } - } val messages = buildList { systemContent?.let { add(ChatMessage("system", it)) } addAll(conversationMessages) 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 f5d9fde4..c15a7a04 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 @@ -222,7 +222,7 @@ abstract class SessionOrchestrator( } ?: emptyList() val promptEntries = stageConfig.metadata["prompt"] ?.let { path -> - runCatching { promptResolver.resolve(path) } + val resolvedText = runCatching { promptResolver.resolve(path) } .onFailure { log.error( "[SessionOrchestrator] stage=${stageId.value}: " + @@ -230,9 +230,11 @@ abstract class SessionOrchestrator( ) } .getOrNull() - } - ?.takeIf { it.isNotBlank() } - ?.let { text -> + val text = resolvedText?.takeIf { it.isNotBlank() } + ?: error( + "[SessionOrchestrator] stage=${stageId.value}: " + + "declared prompt '$path' could not be resolved", + ) listOf( ContextEntry( id = ContextEntryId(UUID.randomUUID().toString()), diff --git a/prompts/healthcheck_execute.md b/examples/workflows/prompts/healthcheck_execute.md similarity index 100% rename from prompts/healthcheck_execute.md rename to examples/workflows/prompts/healthcheck_execute.md diff --git a/prompts/healthcheck_write.md b/examples/workflows/prompts/healthcheck_write.md similarity index 100% rename from prompts/healthcheck_write.md rename to examples/workflows/prompts/healthcheck_write.md diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt index 54251a93..ad38d0ae 100644 --- a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt @@ -21,6 +21,7 @@ import io.ktor.client.request.accept import io.ktor.client.request.get 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.contentType import io.ktor.serialization.kotlinx.json.json @@ -106,11 +107,16 @@ class LlamaCppInferenceProvider( val encoded = json.encodeToString(body) log.debug("sending request to llm: {}", encoded) - val response = httpClient.post("$baseUrl/v1/chat/completions") { + val httpResponse = httpClient.post("$baseUrl/v1/chat/completions") { contentType(ContentType.Application.Json) accept(ContentType.Application.Json) setBody(encoded) - }.body() + } + if (httpResponse.status.value !in 200..299) { + val errorBody = httpResponse.bodyAsText() + error("llama-server returned ${httpResponse.status.value} ${httpResponse.status.description}: $errorBody") + } + val response = httpResponse.body() log.debug("got response from llm: {}", response) @@ -146,4 +152,4 @@ class LlamaCppInferenceProvider( } override fun capabilities(): Set = descriptor.capabilities -} \ No newline at end of file +} diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt index 30699a38..6f7a96a2 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt @@ -15,6 +15,7 @@ import com.fasterxml.jackson.dataformat.toml.TomlMapper import com.fasterxml.jackson.module.kotlin.kotlinModule import com.fasterxml.jackson.module.kotlin.readValue import java.nio.file.Path +import kotlin.io.path.exists import kotlin.io.path.readText private data class WorkflowFile( @@ -67,10 +68,17 @@ class TomlWorkflowLoader( val raw = path.readText() val file = runCatching { mapper.readValue(raw) } .getOrElse { throw WorkflowValidationException("Failed to parse workflow TOML: ${it.message}") } - return file.toWorkflowGraph() + return file.toWorkflowGraph(path.parent) } - private fun WorkflowFile.toWorkflowGraph(): WorkflowGraph { + private fun resolvePromptPath(raw: String, workflowDir: Path?): String { + val p = Path.of(raw) + if (p.isAbsolute) return raw + val candidate = workflowDir?.resolve(raw) + return if (candidate != null && candidate.exists()) candidate.toString() else raw + } + + private fun WorkflowFile.toWorkflowGraph(workflowDir: Path?): WorkflowGraph { val startId = StageId(start) val stageMap = stages.associate { s -> StageId(s.id) to StageConfig( @@ -84,8 +92,8 @@ class TomlWorkflowLoader( tokenBudget = s.tokenBudget, maxRetries = s.maxRetries, metadata = buildMap { - s.prompt?.let { put("prompt", it) } - s.systemPrompt?.let { put("systemPrompt", it) } + s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) } + s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) } }, ) }