Skip to content

Commit 653fd13

Browse files
committed
fix: strip repeated internal metadata leaks
1 parent 115afe0 commit 653fd13

7 files changed

Lines changed: 195 additions & 47 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
407407

408408
## Releases
409409

410+
### v1.9.19 Highlights
411+
412+
Output hygiene release: final assistant responses now use the shared internal metadata scrubber before persistence, UI reset, connector delivery, and completion hooks.
413+
414+
- **Multi-block scrubbing.** Repeated internal metadata payloads are stripped in one pass instead of stopping after the first block.
415+
- **Malformed prelude cleanup.** When a validated internal block is followed by a malformed internal fragment, the leftover prelude is removed before user-facing text is delivered.
416+
- **Shared finalizer path.** Post-stream finalization now uses the same metadata scrubber as the chat UI, keeping stored, streamed, and connector-visible output aligned.
417+
- **Regression coverage.** Tests cover repeated classifier-shape blocks, malformed follow-on fragments, and false-positive protection for malformed text without a prior validated strip.
418+
410419
### v1.9.18 Highlights
411420

412421
Schedule preflight release: schedules now show server-backed timing forecasts before save, with timezone-aware cron previews and warnings for risky drafts.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@swarmclawai/swarmclaw",
3-
"version": "1.9.18",
3+
"version": "1.9.19",
44
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
55
"main": "electron-dist/main.js",
66
"license": "MIT",

src/lib/server/chat-execution/post-stream-finalization.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,28 @@ describe('stripLeakedClassificationJson', () => {
3333
assert.equal(cleaned.includes('taskIntent'), false)
3434
})
3535

36+
it('strips multiple leaked classification JSON blocks', () => {
37+
const input = `${VALID_LEAK}\n${VALID_LEAK}\nTask created and delegated.`
38+
const { cleaned, stripped } = stripLeakedClassificationJson(input)
39+
assert.equal(stripped, true)
40+
assert.equal(cleaned, 'Task created and delegated.')
41+
})
42+
43+
it('strips a malformed internal prelude after a validated leaked block', () => {
44+
const malformedPrelude = [
45+
'{',
46+
' "taskIntent": "research",',
47+
' "isBroadGoal":{',
48+
' false,',
49+
' "isLightweightDirectChat": false,',
50+
'}',
51+
].join('\n')
52+
const input = `${VALID_LEAK}\n${malformedPrelude}\nAll five research bundles reviewed.`
53+
const { cleaned, stripped } = stripLeakedClassificationJson(input)
54+
assert.equal(stripped, true)
55+
assert.equal(cleaned, 'All five research bundles reviewed.')
56+
})
57+
3658
it('leaves normal assistant text untouched', () => {
3759
const input = 'Your favorite color is blue.'
3860
const { cleaned, stripped } = stripLeakedClassificationJson(input)

src/lib/server/chat-execution/post-stream-finalization.ts

Lines changed: 5 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import type { KnowledgeRetrievalTrace, Session, UsageRecord } from '@/types'
99
import { log } from '@/lib/server/logger'
1010
import type { ChatTurnState } from '@/lib/server/chat-execution/chat-turn-state'
11+
import { stripAllInternalMetadata } from '@/lib/strip-internal-metadata'
1112

1213
const TAG = 'post-stream'
1314
import { extractSuggestions } from '@/lib/server/suggestions'
@@ -20,56 +21,16 @@ import {
2021
shouldForceExternalServiceSummary,
2122
} from '@/lib/server/chat-execution/chat-streaming-utils'
2223
import {
23-
MessageClassificationSchema,
2424
type MessageClassification,
2525
} from '@/lib/server/chat-execution/message-classifier'
2626
import {
2727
resolveFinalStreamResponseText,
2828
} from '@/lib/server/chat-execution/stream-continuation'
2929
import { buildForcedExternalServiceSummary } from '@/lib/server/chat-execution/prompt-builder'
3030

31-
// ---------------------------------------------------------------------------
32-
// Classification JSON leak detection — strips MessageClassification objects
33-
// that some models echo verbatim into their response text. Candidate JSON
34-
// substrings are found by brace-matching, then validated against the actual
35-
// MessageClassificationSchema — the single source of truth for what a
36-
// classifier object looks like.
37-
// ---------------------------------------------------------------------------
38-
39-
/** Returns the index just past the balanced `}` for the `{` at `start`, or -1. */
40-
function findBalancedObjectEnd(text: string, start: number): number {
41-
let depth = 0
42-
let inString = false
43-
let escape = false
44-
for (let i = start; i < text.length; i++) {
45-
const ch = text[i]
46-
if (escape) { escape = false; continue }
47-
if (inString) {
48-
if (ch === '\\') escape = true
49-
else if (ch === '"') inString = false
50-
continue
51-
}
52-
if (ch === '"') inString = true
53-
else if (ch === '{') depth += 1
54-
else if (ch === '}') {
55-
depth -= 1
56-
if (depth === 0) return i + 1
57-
}
58-
}
59-
return -1
60-
}
61-
6231
export function stripLeakedClassificationJson(text: string): { cleaned: string; stripped: boolean } {
63-
for (let i = text.indexOf('{'); i !== -1; i = text.indexOf('{', i + 1)) {
64-
const end = findBalancedObjectEnd(text, i)
65-
if (end === -1) break
66-
let parsed: unknown
67-
try { parsed = JSON.parse(text.slice(i, end)) } catch { continue }
68-
if (!MessageClassificationSchema.safeParse(parsed).success) continue
69-
log.warn(TAG, 'Stripped leaked classification JSON from model output')
70-
return { cleaned: (text.slice(0, i) + text.slice(end)).trimStart(), stripped: true }
71-
}
72-
return { cleaned: text, stripped: false }
32+
const cleaned = stripAllInternalMetadata(text)
33+
return { cleaned, stripped: cleaned !== text }
7334
}
7435

7536
// StreamAgentChatResult is defined inline to avoid circular dependency with stream-agent-chat.ts
@@ -174,9 +135,10 @@ export async function finalizeStreamResult(opts: FinalizeStreamResultOpts): Prom
174135
}
175136
}
176137

177-
// Strip leaked classification JSON from model output (e.g. `{ "isDeliverableTask": true, ... }`)
138+
// Strip leaked internal metadata from model output (e.g. `{ "isDeliverableTask": true, ... }`)
178139
const leakResult = stripLeakedClassificationJson(state.fullText)
179140
if (leakResult.stripped) {
141+
log.warn(TAG, 'Stripped leaked internal metadata from model output')
180142
state.fullText = leakResult.cleaned
181143
// Emit a reset so the frontend re-renders with the cleaned text
182144
write(`data: ${JSON.stringify({ t: 'reset', text: leakResult.cleaned })}\n\n`)

src/lib/strip-internal-metadata.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,41 @@ describe('stripInternalJson', () => {
6464
assert.doesNotMatch(result, /isDeliverableTask/)
6565
assert.match(result, /\{ "foo": "bar" \}/)
6666
})
67+
68+
it('removes multiple leading internal JSON blocks', () => {
69+
const input = [
70+
'{ "isDeliverableTask": true, "confidence": 0.9 }',
71+
'{ "factsUpsert": [], "questionsUpsert": [] }',
72+
'All queued work is complete.',
73+
].join('\n')
74+
assert.equal(stripInternalJson(input), 'All queued work is complete.')
75+
})
76+
77+
it('removes a malformed internal prelude only after a strict leading strip', () => {
78+
const input = [
79+
'{ "isDeliverableTask": true, "confidence": 0.9 }',
80+
'{',
81+
' "taskIntent": "research",',
82+
' "isBroadGoal":{',
83+
' false,',
84+
' "isLightweightDirectChat": false,',
85+
'}',
86+
'All queued work is complete.',
87+
].join('\n')
88+
assert.equal(stripInternalJson(input), 'All queued work is complete.')
89+
})
90+
91+
it('preserves malformed internal-looking text without a strict leading strip', () => {
92+
const input = [
93+
'{',
94+
' "taskIntent": "research",',
95+
' "isBroadGoal":{',
96+
' false,',
97+
'}',
98+
'Visible answer.',
99+
].join('\n')
100+
assert.equal(stripInternalJson(input), input)
101+
})
67102
})
68103

69104
// ---------------------------------------------------------------------------

src/lib/strip-internal-metadata.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ const INTERNAL_JSON_KEYS = [
2525

2626
export const INTERNAL_KEY_RE = new RegExp(`"(?:${INTERNAL_JSON_KEYS.join('|')})"`)
2727

28+
const TaskIntentLikeSchema = z.enum(['coding', 'research', 'browsing', 'outreach', 'scheduling', 'general']).optional()
29+
const WorkTypeLikeSchema = z.enum(['coding', 'research', 'writing', 'review', 'operations', 'general']).optional()
30+
2831
const WorkingStatePatchLikeSchema = z.object({
2932
factsUpsert: z.array(z.unknown()).optional(),
3033
artifactsUpsert: z.array(z.unknown()).optional(),
@@ -37,13 +40,15 @@ const WorkingStatePatchLikeSchema = z.object({
3740
}).passthrough()
3841

3942
const MessageClassificationLikeSchema = z.object({
40-
taskIntent: z.string().optional(),
43+
taskIntent: TaskIntentLikeSchema,
4144
isLightweightDirectChat: z.boolean().optional(),
4245
isDeliverableTask: z.boolean().optional(),
4346
isBroadGoal: z.boolean().optional(),
4447
hasHumanSignals: z.boolean().optional(),
48+
hasSignificantEvent: z.boolean().optional(),
4549
explicitToolRequests: z.array(z.unknown()).optional(),
4650
isResearchSynthesis: z.boolean().optional(),
51+
workType: WorkTypeLikeSchema,
4752
confidence: z.number().optional(),
4853
}).passthrough()
4954

@@ -104,6 +109,13 @@ function objectIsInternalMetadata(obj: Record<string, unknown>): boolean {
104109
return false
105110
}
106111

112+
function isDistinctiveInternalKey(key: string): boolean {
113+
for (const { distinctiveKeys } of INTERNAL_PAYLOAD_RULES) {
114+
if (distinctiveKeys.includes(key)) return true
115+
}
116+
return false
117+
}
118+
107119
function findBalancedJsonObjectEnd(text: string, start: number): number {
108120
if (text.charAt(start) !== '{') return -1
109121
let depth = 0
@@ -130,13 +142,117 @@ function findBalancedJsonObjectEnd(text: string, start: number): number {
130142
return -1
131143
}
132144

145+
function parseQuotedKeyAt(text: string, start: number): { key: string; end: number } | null {
146+
if (text.charAt(start) !== '"') return null
147+
let key = ''
148+
let escaped = false
149+
for (let i = start + 1; i < text.length; i += 1) {
150+
const c = text.charAt(i)
151+
if (escaped) {
152+
key += c
153+
escaped = false
154+
continue
155+
}
156+
if (c === '\\') {
157+
escaped = true
158+
continue
159+
}
160+
if (c !== '"') {
161+
key += c
162+
continue
163+
}
164+
let cursor = i + 1
165+
while (cursor < text.length && /\s/.test(text.charAt(cursor))) cursor += 1
166+
if (text.charAt(cursor) !== ':') return null
167+
return { key, end: cursor + 1 }
168+
}
169+
return null
170+
}
171+
172+
function lineHasDistinctiveInternalKey(line: string): boolean {
173+
for (let i = 0; i < line.length; i += 1) {
174+
const parsed = parseQuotedKeyAt(line, i)
175+
if (!parsed) continue
176+
if (isDistinctiveInternalKey(parsed.key)) return true
177+
i = parsed.end - 1
178+
}
179+
return false
180+
}
181+
182+
function startsWithJsonLiteral(text: string, value: string): boolean {
183+
if (!text.startsWith(value)) return false
184+
const next = text.charAt(value.length)
185+
return next === '' || next === ',' || next === '}' || next === ']' || /\s/.test(next)
186+
}
187+
188+
function isMalformedJsonFragmentLine(line: string): boolean {
189+
const trimmed = line.trim()
190+
if (!trimmed) return true
191+
const first = trimmed.charAt(0)
192+
if (first === '{' || first === '}' || first === '[' || first === ']' || first === '"' || first === ',' || first === ':') {
193+
return true
194+
}
195+
if (startsWithJsonLiteral(trimmed, 'true')) return true
196+
if (startsWithJsonLiteral(trimmed, 'false')) return true
197+
if (startsWithJsonLiteral(trimmed, 'null')) return true
198+
if (trimmed.startsWith('...')) return true
199+
return false
200+
}
201+
202+
function findInlineVisibleTextAfterClosingBrace(line: string): number {
203+
for (let i = 0; i < line.length; i += 1) {
204+
if (line.charAt(i) !== '}') continue
205+
let cursor = i + 1
206+
while (cursor < line.length && /\s/.test(line.charAt(cursor))) cursor += 1
207+
const next = line.charAt(cursor)
208+
if (!next || next === ',' || next === '}' || next === ']') continue
209+
return i + 1
210+
}
211+
return -1
212+
}
213+
214+
function findMalformedInternalPreludeEnd(text: string): number {
215+
let leading = 0
216+
while (leading < text.length && /\s/.test(text.charAt(leading))) leading += 1
217+
if (text.charAt(leading) !== '{') return -1
218+
219+
let cursor = leading
220+
let sawDistinctiveKey = false
221+
let consumedEnd = -1
222+
while (cursor < text.length) {
223+
const newlineAt = text.indexOf('\n', cursor)
224+
const lineEnd = newlineAt === -1 ? text.length : newlineAt
225+
const line = text.slice(cursor, lineEnd)
226+
if (lineHasDistinctiveInternalKey(line)) sawDistinctiveKey = true
227+
228+
if (!isMalformedJsonFragmentLine(line)) {
229+
return sawDistinctiveKey && consumedEnd > leading ? consumedEnd : -1
230+
}
231+
232+
const inlineEnd = sawDistinctiveKey ? findInlineVisibleTextAfterClosingBrace(line) : -1
233+
if (inlineEnd >= 0) return cursor + inlineEnd
234+
235+
consumedEnd = newlineAt === -1 ? lineEnd : lineEnd + 1
236+
cursor = consumedEnd
237+
}
238+
239+
return -1
240+
}
241+
242+
function stripMalformedInternalPreludeAfterStrictStrip(text: string): string {
243+
const end = findMalformedInternalPreludeEnd(text)
244+
if (end < 0) return text
245+
return text.slice(end).trimStart()
246+
}
247+
133248
/**
134249
* Remove top-level `{ ... }` blocks that contain known internal classification keys.
135250
* Handles nested and multi-line JSON. Only strips blocks where at least one
136251
* distinctive internal key is present and the payload passes schema validation.
137252
*/
138253
export function stripInternalJson(text: string): string {
139254
let out = text || ''
255+
let removedLeadingInternalJson = false
140256
for (let guard = 0; guard < 32; guard += 1) {
141257
let removed = false
142258
for (let i = 0; i < out.length; i += 1) {
@@ -152,12 +268,16 @@ export function stripInternalJson(text: string): string {
152268
}
153269
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
154270
if (!objectIsInternalMetadata(parsed as Record<string, unknown>)) continue
271+
if (!out.slice(0, i).trim()) removedLeadingInternalJson = true
155272
out = (out.slice(0, i).replace(/\s+$/, '') + ' ' + out.slice(end).replace(/^\s+/, '')).trim()
156273
removed = true
157274
break
158275
}
159276
if (!removed) break
160277
}
278+
if (removedLeadingInternalJson) {
279+
out = stripMalformedInternalPreludeAfterStrictStrip(out)
280+
}
161281
return out
162282
}
163283

0 commit comments

Comments
 (0)