Skip to content

Commit 3bd0eac

Browse files
esrehmkiESRE-dev
authored andcommitted
compaction: eliminate tool-call hallucinations from open-weight models
* Qwen3 and similar models see structured tool-call/tool-result markup in the conversation history and imitate it during compaction, either as structured API calls or raw XML text FIX * convert tool-call/tool-result parts into plain text summaries before sending to the compaction model, so it never sees tool markup to imitate * silently ignore any remaining hallucinated tool calls in the processor instead of crashing the session
1 parent 0f3e831 commit 3bd0eac

2 files changed

Lines changed: 27 additions & 3 deletions

File tree

packages/opencode/src/session/compaction.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,28 @@ When constructing the summary, try to stick to this template:
216216
const msgs = structuredClone(messages)
217217
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
218218
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
219+
220+
// Convert structured tool-call/tool-result parts into plain text so the
221+
// compaction model never sees tool markup and can't hallucinate tool calls.
222+
for (const msg of modelMessages) {
223+
if (!Array.isArray(msg.content)) continue
224+
msg.content = msg.content.map((part: any) => {
225+
if (part.type === "tool-call") {
226+
const inputStr = typeof part.input === "string" ? part.input : JSON.stringify(part.input)
227+
const truncatedInput = inputStr.length > 300 ? inputStr.slice(0, 300) + "... [truncated]" : inputStr
228+
return { type: "text" as const, text: `[Called tool: ${part.toolName}]\n[Input: ${truncatedInput}]` }
229+
}
230+
if (part.type === "tool-result") {
231+
const outputStr = typeof part.output === "string" ? part.output : JSON.stringify(part.output)
232+
const truncatedOutput = outputStr.length > 500 ? outputStr.slice(0, 500) + "... [truncated]" : outputStr
233+
return { type: "text" as const, text: `[Tool result: ${part.toolName}]\n${truncatedOutput}` }
234+
}
235+
return part
236+
})
237+
}
238+
// Remove tool-role messages that are now empty or redundant after transformation
239+
const compactionMessages = modelMessages.filter((msg) => msg.role !== "tool")
240+
219241
const ctx = yield* InstanceState.context
220242
const msg: MessageV2.Assistant = {
221243
id: MessageID.ascending(),
@@ -256,7 +278,7 @@ When constructing the summary, try to stick to this template:
256278
tools: {},
257279
system: [],
258280
messages: [
259-
...modelMessages,
281+
...compactionMessages,
260282
{
261283
role: "user",
262284
content: [{ type: "text", text: prompt }],

packages/opencode/src/session/processor.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,8 @@ export const layer: Layer.Layer<
258258

259259
case "tool-input-start":
260260
if (ctx.assistantMessage.summary) {
261-
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
261+
log.warn("ignoring tool call during summary generation", { tool: value.toolName })
262+
return
262263
}
263264
const part = yield* session.updatePart({
264265
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
@@ -286,7 +287,8 @@ export const layer: Layer.Layer<
286287

287288
case "tool-call": {
288289
if (ctx.assistantMessage.summary) {
289-
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
290+
log.warn("ignoring tool call during summary generation", { tool: value.toolName })
291+
return
290292
}
291293
yield* updateToolCall(value.toolCallId, (match) => ({
292294
...match,

0 commit comments

Comments
 (0)