@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 +9 -0
- package/package.json +1 -1
- package/src/lib/server/chat-execution/post-stream-finalization.test.ts +22 -0
- package/src/lib/server/chat-execution/post-stream-finalization.ts +5 -43
- package/src/lib/strip-internal-metadata.test.ts +35 -0
- package/src/lib/strip-internal-metadata.ts +121 -1
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.
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
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:
|
|
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
|
|