@swarmclawai/swarmclaw 1.9.18 → 1.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -407,6 +407,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
407
407
 
408
408
  ## Releases
409
409
 
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
+
410
419
  ### v1.9.18 Highlights
411
420
 
412
421
  Schedule preflight release: schedules now show server-backed timing forecasts before save, with timezone-aware cron previews and warnings for risky drafts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.18",
3
+ "version": "1.9.19",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -33,6 +33,28 @@ describe('stripLeakedClassificationJson', () => {
33
33
  assert.equal(cleaned.includes('taskIntent'), false)
34
34
  })
35
35
 
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
+
36
58
  it('leaves normal assistant text untouched', () => {
37
59
  const input = 'Your favorite color is blue.'
38
60
  const { cleaned, stripped } = stripLeakedClassificationJson(input)
@@ -8,6 +8,7 @@
8
8
  import type { KnowledgeRetrievalTrace, Session, UsageRecord } from '@/types'
9
9
  import { log } from '@/lib/server/logger'
10
10
  import type { ChatTurnState } from '@/lib/server/chat-execution/chat-turn-state'
11
+ import { stripAllInternalMetadata } from '@/lib/strip-internal-metadata'
11
12
 
12
13
  const TAG = 'post-stream'
13
14
  import { extractSuggestions } from '@/lib/server/suggestions'
@@ -20,7 +21,6 @@ import {
20
21
  shouldForceExternalServiceSummary,
21
22
  } from '@/lib/server/chat-execution/chat-streaming-utils'
22
23
  import {
23
- MessageClassificationSchema,
24
24
  type MessageClassification,
25
25
  } from '@/lib/server/chat-execution/message-classifier'
26
26
  import {
@@ -28,48 +28,9 @@ import {
28
28
  } from '@/lib/server/chat-execution/stream-continuation'
29
29
  import { buildForcedExternalServiceSummary } from '@/lib/server/chat-execution/prompt-builder'
30
30
 
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
-
62
31
  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 }
73
34
  }
74
35
 
75
36
  // StreamAgentChatResult is defined inline to avoid circular dependency with stream-agent-chat.ts
@@ -174,9 +135,10 @@ export async function finalizeStreamResult(opts: FinalizeStreamResultOpts): Prom
174
135
  }
175
136
  }
176
137
 
177
- // Strip leaked classification JSON from model output (e.g. `{ "isDeliverableTask": true, ... }`)
138
+ // Strip leaked internal metadata from model output (e.g. `{ "isDeliverableTask": true, ... }`)
178
139
  const leakResult = stripLeakedClassificationJson(state.fullText)
179
140
  if (leakResult.stripped) {
141
+ log.warn(TAG, 'Stripped leaked internal metadata from model output')
180
142
  state.fullText = leakResult.cleaned
181
143
  // Emit a reset so the frontend re-renders with the cleaned text
182
144
  write(`data: ${JSON.stringify({ t: 'reset', text: leakResult.cleaned })}\n\n`)
@@ -64,6 +64,41 @@ describe('stripInternalJson', () => {
64
64
  assert.doesNotMatch(result, /isDeliverableTask/)
65
65
  assert.match(result, /\{ "foo": "bar" \}/)
66
66
  })
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
+ })
67
102
  })
68
103
 
69
104
  // ---------------------------------------------------------------------------
@@ -25,6 +25,9 @@ const INTERNAL_JSON_KEYS = [
25
25
 
26
26
  export const INTERNAL_KEY_RE = new RegExp(`"(?:${INTERNAL_JSON_KEYS.join('|')})"`)
27
27
 
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
+
28
31
  const WorkingStatePatchLikeSchema = z.object({
29
32
  factsUpsert: z.array(z.unknown()).optional(),
30
33
  artifactsUpsert: z.array(z.unknown()).optional(),
@@ -37,13 +40,15 @@ const WorkingStatePatchLikeSchema = z.object({
37
40
  }).passthrough()
38
41
 
39
42
  const MessageClassificationLikeSchema = z.object({
40
- taskIntent: z.string().optional(),
43
+ taskIntent: TaskIntentLikeSchema,
41
44
  isLightweightDirectChat: z.boolean().optional(),
42
45
  isDeliverableTask: z.boolean().optional(),
43
46
  isBroadGoal: z.boolean().optional(),
44
47
  hasHumanSignals: z.boolean().optional(),
48
+ hasSignificantEvent: z.boolean().optional(),
45
49
  explicitToolRequests: z.array(z.unknown()).optional(),
46
50
  isResearchSynthesis: z.boolean().optional(),
51
+ workType: WorkTypeLikeSchema,
47
52
  confidence: z.number().optional(),
48
53
  }).passthrough()
49
54
 
@@ -104,6 +109,13 @@ function objectIsInternalMetadata(obj: Record<string, unknown>): boolean {
104
109
  return false
105
110
  }
106
111
 
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
+
107
119
  function findBalancedJsonObjectEnd(text: string, start: number): number {
108
120
  if (text.charAt(start) !== '{') return -1
109
121
  let depth = 0
@@ -130,6 +142,109 @@ function findBalancedJsonObjectEnd(text: string, start: number): number {
130
142
  return -1
131
143
  }
132
144
 
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
+
133
248
  /**
134
249
  * Remove top-level `{ ... }` blocks that contain known internal classification keys.
135
250
  * Handles nested and multi-line JSON. Only strips blocks where at least one
@@ -137,6 +252,7 @@ function findBalancedJsonObjectEnd(text: string, start: number): number {
137
252
  */
138
253
  export function stripInternalJson(text: string): string {
139
254
  let out = text || ''
255
+ let removedLeadingInternalJson = false
140
256
  for (let guard = 0; guard < 32; guard += 1) {
141
257
  let removed = false
142
258
  for (let i = 0; i < out.length; i += 1) {
@@ -152,12 +268,16 @@ export function stripInternalJson(text: string): string {
152
268
  }
153
269
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
154
270
  if (!objectIsInternalMetadata(parsed as Record<string, unknown>)) continue
271
+ if (!out.slice(0, i).trim()) removedLeadingInternalJson = true
155
272
  out = (out.slice(0, i).replace(/\s+$/, '') + ' ' + out.slice(end).replace(/^\s+/, '')).trim()
156
273
  removed = true
157
274
  break
158
275
  }
159
276
  if (!removed) break
160
277
  }
278
+ if (removedLeadingInternalJson) {
279
+ out = stripMalformedInternalPreludeAfterStrictStrip(out)
280
+ }
161
281
  return out
162
282
  }
163
283