fix(context): name emit_artifact in the schema instruction (#416)

The pinned schemaInstruction said only "Respond with a single JSON object
matching this schema", contradicting every role prompt that says to emit via
the emit_artifact tool. Two stated channels for one artifact, and the pinned
one won.

ResponseFormat.Json is built under exactly the condition that offers the tool
(an llmEmitted slot — see emitArtifactTool), so the tool is always available
wherever this instruction renders, and the kernel prefers it: tool-calling
models are more reliable at it and it sidesteps llama.cpp's grammar+tools
incompatibility. The instruction now names it, and keeps raw JSON as what to do
when told to stop calling tools — which is what the tools-less final pass
demands and what the executor already accepts either way
(llmArtifactOverride ?: response.text).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 01:03:23 +04:00
parent a9196a0037
commit c70b6779a3
2 changed files with 17 additions and 3 deletions
@@ -52,11 +52,21 @@ internal fun SessionOrchestrator.evictArtifactContentCache(sessionId: SessionId)
internal suspend fun SessionOrchestrator.buildSchemaEntries(
responseFormat: ResponseFormat,
stageId: StageId,
artifactName: String,
): List<ContextEntry> {
if (responseFormat !is ResponseFormat.Json) return emptyList()
val compactSchema = Json.encodeToString(JsonSchema.serializer(), responseFormat.schema)
val instruction = "Respond with a single JSON object matching this schema. " +
"Do not include markdown, code fences, or commentary outside the JSON. " +
// #416: names emit_artifact as the channel. ResponseFormat.Json is emitted under exactly the
// condition that offers the tool (an llmEmitted slot — see emitArtifactTool), so the tool is
// always available here, and the kernel prefers it: tool-calling models are more reliable at it
// and it sidesteps llama.cpp's grammar+tools incompatibility. The old text said only "respond
// with a single JSON object", contradicting every role prompt that says to call emit_artifact.
// Raw JSON stays valid as the second sentence describes, since the executor accepts either
// (llmArtifactOverride ?: response.text) and the tools-less final pass explicitly demands it.
val instruction = "Produce the '$artifactName' artifact by calling the $EMIT_ARTIFACT_TOOL tool " +
"with its fields filled in, matching this schema. If you are told to stop calling tools, " +
"output the same object as a single JSON object in your final message instead. Either way, " +
"no markdown, no code fences, and no commentary outside the JSON. " +
"Schema: $compactSchema"
return listOf(
ContextEntry(
@@ -152,7 +152,11 @@ internal suspend fun SessionOrchestrator.executeStage(
?.let { ResponseFormat.Json(it.kind.deriveJsonSchema()) }
?: ResponseFormat.Text
val schemaEntries = buildSchemaEntries(responseFormat, stageId)
val schemaEntries = buildSchemaEntries(
responseFormat,
stageId,
llmEmittedSlots.firstOrNull()?.name?.value ?: "",
)
val intentEntries = buildIntentEntry(sessionId)
val steeringEntries = buildSteeringNoteEntries(sessionId)
val clarificationEntries = buildClarificationAnswerEntries(sessionId)