feat(context): reasoning-thread continuity, L3 doc filtering, and gate hardening

Threads reasoning_content across inference calls and journal renders, filters
markdown noise out of L3 repo-knowledge retrieval, adds PlanLinter H3 checks,
and tightens filesystem tool output/dir-listing behavior surfaced by prior
live QA (see project_readloop_campaign memory).
This commit is contained in:
2026-07-10 11:13:43 +04:00
parent f51a8dada4
commit 3d5e05c1fb
32 changed files with 742 additions and 85 deletions
@@ -142,9 +142,15 @@ class LlamaCppInferenceProvider(
val baseMessages = PromptRenderer.render(request.contextPack)
val messages = baseMessages.map { msg ->
val toolCalls = if (msg.role == "assistant") {
parseAssistantToolCalls(msg.content)
} else emptyList()
LlamaCppChatMessage(
role = msg.role,
content = msg.content,
content = if (toolCalls.isNotEmpty()) null else msg.content,
reasoningContent = msg.reasoning,
toolCalls = toolCalls,
toolCallId = msg.toolCallId,
)
}
@@ -230,5 +236,40 @@ class LlamaCppInferenceProvider(
ProviderHealth.Unavailable("Health check failed: ${e.message}")
}
/**
* Parses an assistant message's content back into [ToolCallRequest] objects.
*
* The context builder's FORMAT_COMPRESS stage converts raw ToolCallRequest JSON into a compact
* dotted-line format (id = ..., function.name = ...). If compression is disabled, the content
* is still valid JSON. This handles both forms so the Gemma4 Jinja template sees the native
* `tool_calls` array — which it requires to render `reasoning_content` as a `<|channel>thought`
* block (without `tool_calls` the reasoning field is silently dropped and the model re-enters
* thought mode from scratch, causing the degenerate `<|channel>thought`~16384-token loop).
*/
private fun parseAssistantToolCalls(content: String): List<ToolCallRequest> {
// Try JSON parse first (FORMAT_COMPRESS disabled — raw ToolCallRequest JSON).
if (content.startsWith("{\"id\"")) {
runCatching {
return listOf(json.decodeFromString<ToolCallRequest>(content))
}
}
// Try dotted-line format (FORMAT_COMPRESS enabled).
if (!content.contains("function.name")) return emptyList()
val lines = content.split('\n')
var id: String? = null
var name: String? = null
var arguments: String? = null
for (line in lines) {
when {
line.startsWith("id = ") -> id = line.removePrefix("id = ").trim()
line.startsWith("function.name = ") -> name = line.removePrefix("function.name = ").trim()
line.startsWith("function.arguments = ") -> arguments = line.removePrefix("function.arguments = ").trim()
}
}
return if (name != null && arguments != null) {
listOf(ToolCallRequest(id = id, function = ToolCallFunction(name = name, arguments = arguments)))
} else emptyList()
}
override fun capabilities(): Set<CapabilityScore> = descriptor.capabilities
}
@@ -88,7 +88,7 @@ class OpenAiCompatInferenceProvider(
// tool_calls turn (we don't carry tool_call_ids through the context pack). Fold any
// non-standard role into a user turn so the model still sees the content.
if (msg.role in STANDARD_ROLES) {
OpenAiChatMessage(role = msg.role, content = msg.content)
OpenAiChatMessage(role = msg.role, content = msg.content, reasoningContent = msg.reasoning)
} else {
OpenAiChatMessage(role = "user", content = "[${msg.role}] ${msg.content}")
}