fix: workflow inference chain — surface llama-server errors, user-turn-last, bundle-relative prompts

- 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/)
This commit is contained in:
2026-06-01 23:23:29 +04:00
parent 55a18b4b3a
commit 94f7ad0ee9
6 changed files with 50 additions and 23 deletions
@@ -12,6 +12,14 @@ data class ChatMessage(
) )
object PromptRenderer { 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<ContextLayer> = 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<ChatMessage> { fun render(contextPack: ContextPack): List<ChatMessage> {
val sorted = contextPack.layers.entries.sortedBy { it.key.ordinal } val sorted = contextPack.layers.entries.sortedBy { it.key.ordinal }
val systemContent = sorted val systemContent = sorted
@@ -19,19 +27,22 @@ object PromptRenderer {
.flatMap { it.value } .flatMap { it.value }
.joinToString("\n\n") { it.content } .joinToString("\n\n") { it.content }
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
val conversationMessages = sorted.filter { it.key != ContextLayer.L0 }.flatMap { (_, entries) -> val conversationMessages = contextPack.layers.entries
entries.map { entry -> .filter { it.key != ContextLayer.L0 }
ChatMessage( .sortedWith(Comparator.comparing({ it.key }, nonL0RenderOrder))
role = when (entry.role) { .flatMap { (_, entries) ->
EntryRole.SYSTEM -> "system" entries.map { entry ->
EntryRole.ASSISTANT -> "assistant" ChatMessage(
EntryRole.TOOL -> "tool" role = when (entry.role) {
EntryRole.USER -> "user" EntryRole.SYSTEM -> "system"
}, EntryRole.ASSISTANT -> "assistant"
content = entry.content, EntryRole.TOOL -> "tool"
) EntryRole.USER -> "user"
},
content = entry.content,
)
}
} }
}
val messages = buildList { val messages = buildList {
systemContent?.let { add(ChatMessage("system", it)) } systemContent?.let { add(ChatMessage("system", it)) }
addAll(conversationMessages) addAll(conversationMessages)
@@ -222,7 +222,7 @@ abstract class SessionOrchestrator(
} ?: emptyList() } ?: emptyList()
val promptEntries = stageConfig.metadata["prompt"] val promptEntries = stageConfig.metadata["prompt"]
?.let { path -> ?.let { path ->
runCatching { promptResolver.resolve(path) } val resolvedText = runCatching { promptResolver.resolve(path) }
.onFailure { .onFailure {
log.error( log.error(
"[SessionOrchestrator] stage=${stageId.value}: " + "[SessionOrchestrator] stage=${stageId.value}: " +
@@ -230,9 +230,11 @@ abstract class SessionOrchestrator(
) )
} }
.getOrNull() .getOrNull()
} val text = resolvedText?.takeIf { it.isNotBlank() }
?.takeIf { it.isNotBlank() } ?: error(
?.let { text -> "[SessionOrchestrator] stage=${stageId.value}: " +
"declared prompt '$path' could not be resolved",
)
listOf( listOf(
ContextEntry( ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()), id = ContextEntryId(UUID.randomUUID().toString()),
@@ -21,6 +21,7 @@ import io.ktor.client.request.accept
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody 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.http.contentType import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
@@ -106,11 +107,16 @@ class LlamaCppInferenceProvider(
val encoded = json.encodeToString(body) val encoded = json.encodeToString(body)
log.debug("sending request to llm: {}", encoded) 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) contentType(ContentType.Application.Json)
accept(ContentType.Application.Json) accept(ContentType.Application.Json)
setBody(encoded) setBody(encoded)
}.body<ChatCompletionResponse>() }
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<ChatCompletionResponse>()
log.debug("got response from llm: {}", response) log.debug("got response from llm: {}", response)
@@ -146,4 +152,4 @@ class LlamaCppInferenceProvider(
} }
override fun capabilities(): Set<CapabilityScore> = descriptor.capabilities override fun capabilities(): Set<CapabilityScore> = descriptor.capabilities
} }
@@ -15,6 +15,7 @@ import com.fasterxml.jackson.dataformat.toml.TomlMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.readText import kotlin.io.path.readText
private data class WorkflowFile( private data class WorkflowFile(
@@ -67,10 +68,17 @@ class TomlWorkflowLoader(
val raw = path.readText() val raw = path.readText()
val file = runCatching { mapper.readValue<WorkflowFile>(raw) } val file = runCatching { mapper.readValue<WorkflowFile>(raw) }
.getOrElse { throw WorkflowValidationException("Failed to parse workflow TOML: ${it.message}") } .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 startId = StageId(start)
val stageMap = stages.associate { s -> val stageMap = stages.associate { s ->
StageId(s.id) to StageConfig( StageId(s.id) to StageConfig(
@@ -84,8 +92,8 @@ class TomlWorkflowLoader(
tokenBudget = s.tokenBudget, tokenBudget = s.tokenBudget,
maxRetries = s.maxRetries, maxRetries = s.maxRetries,
metadata = buildMap { metadata = buildMap {
s.prompt?.let { put("prompt", it) } s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) }
s.systemPrompt?.let { put("systemPrompt", it) } s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) }
}, },
) )
} }