Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const server: Plugin = (async (ctx) => {
prompts,
hostPermissions,
) as any,
"experimental.text.complete": createTextCompleteHandler(),
"experimental.text.complete": createTextCompleteHandler(state),
"command.execute.before": createCommandExecuteHandler(
ctx.client,
state,
Expand Down
7 changes: 5 additions & 2 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,12 +289,15 @@ export function createCommandExecuteHandler(
}
}

export function createTextCompleteHandler() {
export function createTextCompleteHandler(state: SessionState) {
return async (
_input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => {
output.text = stripHallucinationsFromString(output.text)
// The session format decides which tags are recognised: compact sessions
// emit @7@ refs, and cleaning with the default XML rules leaves them in
// the rendered reply (#632).
output.text = stripHallucinationsFromString(output.text, state.idFormat)
}
}

Expand Down
10 changes: 10 additions & 0 deletions lib/messages/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ const DCP_UNPAIRED_TAG_REGEX = /<\/?dcp[^>]*>/gi
const INJECTED_MESSAGE_ID_SUFFIX_REGEX = /(?<=\n)<dcp-message-id[^>]*>m\d+<\/dcp-message-id>\s*$/
const HALLUCINATED_PARAMETER_SUFFIX_REGEX = /(?<=\n)m\d+<\/parameter>\s*$/
const COMPACT_TAG_REGEX = /@(?:[1-9]\d*|b[1-9]\d*|blocked)@(?:[ \t]+\[(?:low|medium|high)\])?/gi
// Models that imitate the injected tag often emit broken fragments instead of a
// well formed one: a run of trailing lines made only of bracket debris such as
// "<<", "<" or "<m0477" (see #562). The run is anchored to the end of the reply
// and needs at least two lines, so a lone "<" in prose, markdown or code stays
// intact.
const DEGENERATE_TAG_LINE_PATTERN = String.raw`[ \t]*<{1,2}\/?[ \t]*(?:dcp[a-z-]*|m\d+|b\d+)?[ \t]*>?[ \t]*`
const DEGENERATE_TAG_DEBRIS_REGEX = new RegExp(
String.raw`(?:\n${DEGENERATE_TAG_LINE_PATTERN}){2,}\n?$`,
)

const generateStableId = (prefix: string, seed: string): string => {
const hash = createHash("sha256").update(seed).digest("hex").slice(0, SUMMARY_ID_HASH_LENGTH)
Expand Down Expand Up @@ -175,6 +184,7 @@ export const stripHallucinationsFromString = (text: string, format: IdFormat = "
return withoutKnownSuffixes
.replace(DCP_PAIRED_TAG_REGEX, "")
.replace(DCP_UNPAIRED_TAG_REGEX, "")
.replace(DEGENERATE_TAG_DEBRIS_REGEX, "")
}

export const stripHallucinations = (messages: WithParts[], format: IdFormat = "xml"): void => {
Expand Down
2 changes: 1 addition & 1 deletion tests/hooks-permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ test("command execute exits after effective permission resolves to deny", async

test("text complete strips hallucinated metadata tags", async () => {
const output = { text: "alpha <dcp>beta</dcp> omega" }
const handler = createTextCompleteHandler()
const handler = createTextCompleteHandler(createSessionState())

await handler({ sessionID: "session-1", messageID: "message-1", partID: "part-1" }, output)

Expand Down
2 changes: 1 addition & 1 deletion tests/message-priority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@ test("hallucination stripping removes all dcp-prefixed XML tags including varian

assert.equal(stripHallucinationsFromString(text), "alphaomega")

const handler = createTextCompleteHandler()
const handler = createTextCompleteHandler(createSessionState())
const output = { text }
await handler({ sessionID: "session", messageID: "message", partID: "part" }, output)
assert.equal(output.text, "alphaomega")
Expand Down
87 changes: 87 additions & 0 deletions tests/text-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import assert from "node:assert/strict"
import test from "node:test"
import { mkdtempSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"

const root = mkdtempSync(join(tmpdir(), "dcp-text-cleanup-"))
process.env.XDG_DATA_HOME = join(root, "data")
process.env.XDG_CONFIG_HOME = join(root, "config")
process.env.OPENCODE_CONFIG_DIR = join(root, "config", "opencode")
test.after(() => rmSync(root, { recursive: true, force: true }))

const { createSessionState } = await import("../lib/state")
const { stripHallucinationsFromString } = await import("../lib/messages/utils")
const { createTextCompleteHandler } = await import("../lib/hooks")

// Verbatim from #562: the reply a model produced after being shown the
// <dcp-message-id> tag, with every fragment of that tag left unclosed.
const KIMI_REPLY = [
"Which do you prefer?",
"<<",
"<<",
"<<",
"<<",
"<<",
"<<",
"<<",
"<",
"<m0477",
"<",
"<",
"<",
].join("\n")

test("broken tag debris at the end of a reply is removed", () => {
assert.equal(stripHallucinationsFromString(KIMI_REPLY), "Which do you prefer?")
})

test("a single stray angle bracket is left alone", () => {
const code = "if (a<b && c>d) {\n return 1\n}"
assert.equal(stripHallucinationsFromString(code), code)

const prose = "Compare a < b"
assert.equal(stripHallucinationsFromString(prose), prose)

const oneLine = "The tag looks like <m0477"
assert.equal(stripHallucinationsFromString(oneLine), oneLine)
})

test("debris that is not at the end of the reply is left alone", () => {
// Cleanup is deliberately conservative: only a trailing run is removed, so
// bracket-only lines in the middle of a reply are never rewritten.
const text = ["intro", "<<", "<m0477", "still talking"].join("\n")
assert.equal(stripHallucinationsFromString(text), text)
})

test("code blocks containing brackets are not touched", () => {
const text = ["```ts", "<<", "<", "```", "done"].join("\n")
assert.equal(stripHallucinationsFromString(text), text)
})

test("well formed injected tags are still stripped", () => {
assert.equal(
stripHallucinationsFromString("answer\n<dcp-message-id>m0477</dcp-message-id>"),
"answer\n",
)
assert.equal(stripHallucinationsFromString("m0477</parameter>"), "m0477</parameter>")
})

test("text.complete strips echoed refs for compact sessions", async () => {
const state = createSessionState("compact")
const handler = createTextCompleteHandler(state)

// Verbatim from #632: a model copying the compact reference tag into its
// reply. The handler used the XML cleanup rules, which do not match @N@.
const output = { text: "I will use @7@ [high] to compress.\n@b1@ done\n@blocked@" }
await handler({ sessionID: "ses", messageID: "m", partID: "p" }, output)
assert.equal(output.text, "I will use to compress.\n done\n")
})

test("text.complete leaves ordinary compact text untouched", async () => {
const state = createSessionState("compact")
const handler = createTextCompleteHandler(state)
const output = { text: "mail me at [email protected], see <https://x.dev> for the patch" }
await handler({ sessionID: "ses", messageID: "m", partID: "p" }, output)
assert.equal(output.text, "mail me at [email protected], see <https://x.dev> for the patch")
})