@swarmclawai/swarmclaw 1.9.1 → 1.9.3
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 +21 -1
- package/electron-dist/main.js +218 -0
- package/package.json +3 -3
- package/scripts/ensure-sandbox-browser-image.mjs +12 -2
- package/src/app/api/extensions/managed-resources/route.test.ts +117 -0
- package/src/app/api/extensions/managed-resources/route.ts +116 -0
- package/src/app/api/knowledge/hygiene/route.ts +19 -1
- package/src/app/api/portability/export/route.test.ts +17 -0
- package/src/app/api/portability/export/route.ts +11 -2
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/lib/server/agents/delegation-advisory.test.ts +1 -0
- package/src/lib/server/agents/delegation-advisory.ts +10 -0
- package/src/lib/server/chat-execution/iteration-event-handler.ts +24 -8
- package/src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts +117 -0
- package/src/lib/server/chat-execution/reasoning-tag-scrubber.ts +219 -0
- package/src/lib/server/extension-managed-resources.test.ts +159 -0
- package/src/lib/server/extension-managed-resources.ts +905 -0
- package/src/lib/server/extensions.ts +113 -2
- package/src/lib/server/knowledge-sources.test.ts +45 -0
- package/src/lib/server/knowledge-sources.ts +33 -0
- package/src/lib/server/portability/export.ts +10 -0
- package/src/lib/server/session-tools/crud.ts +25 -2
- package/src/lib/server/session-tools/extension-creator.ts +50 -0
- package/src/lib/server/session-tools/manage-tasks.test.ts +7 -2
- package/src/lib/server/tasks/task-route-service.ts +18 -1
- package/src/lib/server/tasks/task-service.test.ts +60 -2
- package/src/lib/server/tasks/task-service.ts +35 -0
- package/src/lib/validation/schemas.ts +2 -0
- package/src/types/agent.ts +2 -0
- package/src/types/app-settings.ts +8 -0
- package/src/types/extension.ts +132 -0
- package/src/types/misc.ts +1 -1
- package/src/types/schedule.ts +3 -0
- package/src/views/settings/extension-manager.tsx +157 -1
|
@@ -124,6 +124,7 @@ describe('delegation-advisory', () => {
|
|
|
124
124
|
assert.equal(result.shouldDelegate, true)
|
|
125
125
|
assert.equal(result.style, 'managerial')
|
|
126
126
|
assert.equal(result.recommended?.agentId, 'builder')
|
|
127
|
+
assert.equal(result.recommended?.routeKey, 'coding:coding,debugging,implementation:builder')
|
|
127
128
|
assert.match(advisory.formatDelegationRationale(result.recommended), /coding/i)
|
|
128
129
|
})
|
|
129
130
|
|
|
@@ -20,6 +20,7 @@ export interface DelegationTaskProfile {
|
|
|
20
20
|
export interface DelegationCandidateFit {
|
|
21
21
|
agentId: string
|
|
22
22
|
agentName: string
|
|
23
|
+
routeKey: string
|
|
23
24
|
score: number
|
|
24
25
|
availability: 'idle' | 'working' | 'unknown'
|
|
25
26
|
matchedCapabilities: string[]
|
|
@@ -76,6 +77,14 @@ function matchedCapabilities(agentCapabilities: string[] | undefined, requiredCa
|
|
|
76
77
|
return requiredCapabilities.filter((entry) => agentSet.has(entry.toLowerCase()))
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
function buildRouteKey(agent: Agent, profile: DelegationTaskProfile): string {
|
|
81
|
+
const required = profile.requiredCapabilities
|
|
82
|
+
.map((entry) => entry.toLowerCase())
|
|
83
|
+
.sort()
|
|
84
|
+
.join(',')
|
|
85
|
+
return [profile.workType, required || 'general', agent.id].join(':')
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
function roleAdjustment(agent: Agent, profile: DelegationTaskProfile): number {
|
|
80
89
|
const role = agent.role === 'coordinator' ? 'coordinator' : 'worker'
|
|
81
90
|
if (profile.workType === 'operations') {
|
|
@@ -141,6 +150,7 @@ function buildCandidateFit(
|
|
|
141
150
|
return {
|
|
142
151
|
agentId: agent.id,
|
|
143
152
|
agentName: agent.name,
|
|
153
|
+
routeKey: buildRouteKey(agent, profile),
|
|
144
154
|
score,
|
|
145
155
|
availability,
|
|
146
156
|
matchedCapabilities: matched,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
createReasoningContentMetadata,
|
|
31
31
|
extractReasoningContentDelta,
|
|
32
32
|
} from '@/lib/providers/deepseek-reasoning-chat-openai'
|
|
33
|
+
import { StreamingReasoningTagScrubber } from '@/lib/server/chat-execution/reasoning-tag-scrubber'
|
|
33
34
|
|
|
34
35
|
// ---------------------------------------------------------------------------
|
|
35
36
|
// LangGraph event kind constants
|
|
@@ -92,10 +93,24 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
|
|
|
92
93
|
let toolEndCount = 0
|
|
93
94
|
const iterationText = { value: '' }
|
|
94
95
|
const toolPerfEnds = new Map<string, (extra?: Record<string, unknown>) => number>()
|
|
96
|
+
const reasoningTagScrubber = new StreamingReasoningTagScrubber()
|
|
95
97
|
|
|
96
98
|
/** Interval for progress checkpoint nudges */
|
|
97
99
|
const PROGRESS_CHECK_INTERVAL = 10
|
|
98
100
|
|
|
101
|
+
const emitThinking = (text: string) => {
|
|
102
|
+
if (!text) return
|
|
103
|
+
state.accumulatedThinking += text
|
|
104
|
+
write(`data: ${JSON.stringify({ t: 'thinking', text })}\n\n`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const appendScrubbedText = (text: string) => {
|
|
108
|
+
if (!text) return
|
|
109
|
+
const scrubbed = reasoningTagScrubber.feed(text)
|
|
110
|
+
if (scrubbed.reasoning) emitThinking(scrubbed.reasoning)
|
|
111
|
+
if (scrubbed.visible) state.appendText(scrubbed.visible, iterationText, write)
|
|
112
|
+
}
|
|
113
|
+
|
|
99
114
|
for await (const event of eventStream) {
|
|
100
115
|
const kind = event.event
|
|
101
116
|
|
|
@@ -104,27 +119,24 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
|
|
|
104
119
|
const chunk = event.data?.chunk
|
|
105
120
|
const reasoningDelta = extractReasoningContentDelta(chunk?.additional_kwargs as Record<string, unknown> | undefined)
|
|
106
121
|
if (reasoningDelta) {
|
|
107
|
-
|
|
108
|
-
write(`data: ${JSON.stringify({ t: 'thinking', text: reasoningDelta })}\n\n`)
|
|
122
|
+
emitThinking(reasoningDelta)
|
|
109
123
|
write(`data: ${JSON.stringify({ t: 'md', text: JSON.stringify(createReasoningContentMetadata(reasoningDelta)) })}\n\n`)
|
|
110
124
|
}
|
|
111
125
|
if (chunk?.content) {
|
|
112
126
|
if (Array.isArray(chunk.content)) {
|
|
113
127
|
for (const block of chunk.content) {
|
|
114
128
|
if (block.type === 'thinking' && block.thinking) {
|
|
115
|
-
|
|
116
|
-
write(`data: ${JSON.stringify({ t: 'thinking', text: block.thinking })}\n\n`)
|
|
129
|
+
emitThinking(block.thinking)
|
|
117
130
|
} else if (typeof block.text === 'string' && block.text.startsWith('[[thinking]]')) {
|
|
118
|
-
|
|
119
|
-
write(`data: ${JSON.stringify({ t: 'thinking', text: block.text.slice(12) })}\n\n`)
|
|
131
|
+
emitThinking(block.text.slice(12))
|
|
120
132
|
} else if (block.text) {
|
|
121
|
-
|
|
133
|
+
appendScrubbedText(block.text)
|
|
122
134
|
}
|
|
123
135
|
}
|
|
124
136
|
} else {
|
|
125
137
|
const text = typeof chunk.content === 'string' ? chunk.content : ''
|
|
126
138
|
if (text) {
|
|
127
|
-
|
|
139
|
+
appendScrubbedText(text)
|
|
128
140
|
}
|
|
129
141
|
}
|
|
130
142
|
}
|
|
@@ -351,6 +363,10 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
|
|
|
351
363
|
}
|
|
352
364
|
}
|
|
353
365
|
|
|
366
|
+
const finalScrubbed = reasoningTagScrubber.flush()
|
|
367
|
+
if (finalScrubbed.reasoning) emitThinking(finalScrubbed.reasoning)
|
|
368
|
+
if (finalScrubbed.visible) state.appendText(finalScrubbed.visible, iterationText, write)
|
|
369
|
+
|
|
354
370
|
return {
|
|
355
371
|
reachedExecutionBoundary,
|
|
356
372
|
executionFollowthroughReason,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import { StreamingReasoningTagScrubber } from './reasoning-tag-scrubber'
|
|
4
|
+
import { ChatTurnState } from './chat-turn-state'
|
|
5
|
+
import { processIterationEvents } from './iteration-event-handler'
|
|
6
|
+
|
|
7
|
+
function drive(deltas: string[]) {
|
|
8
|
+
const scrubber = new StreamingReasoningTagScrubber()
|
|
9
|
+
let visible = ''
|
|
10
|
+
let reasoning = ''
|
|
11
|
+
for (const delta of deltas) {
|
|
12
|
+
const result = scrubber.feed(delta)
|
|
13
|
+
visible += result.visible
|
|
14
|
+
reasoning += result.reasoning
|
|
15
|
+
}
|
|
16
|
+
const final = scrubber.flush()
|
|
17
|
+
visible += final.visible
|
|
18
|
+
reasoning += final.reasoning
|
|
19
|
+
return { visible, reasoning }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('strips closed reasoning pairs from visible text and captures reasoning', () => {
|
|
23
|
+
assert.deepEqual(
|
|
24
|
+
drive(['Hello <think>private note</think> world']),
|
|
25
|
+
{ visible: 'Hello world', reasoning: 'private note' },
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('handles all supported tag variants case-insensitively', () => {
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
drive(['<THINKING>a</Thinking><reasoning>b</reasoning><thought>c</thought><REASONING_SCRATCHPAD>d</REASONING_SCRATCHPAD>done']),
|
|
32
|
+
{ visible: 'done', reasoning: 'abcd' },
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('holds split opening tags across stream deltas', () => {
|
|
37
|
+
assert.deepEqual(
|
|
38
|
+
drive(['<', 'think>reasoning</think>', 'done']),
|
|
39
|
+
{ visible: 'done', reasoning: 'reasoning' },
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('captures reasoning until a split close tag resolves', () => {
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
drive(['<think>first', ' second</th', 'ink>answer']),
|
|
46
|
+
{ visible: 'answer', reasoning: 'first second' },
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('does not treat a mid-line prose mention as an open reasoning block', () => {
|
|
51
|
+
const text = 'Use the <think> element for examples'
|
|
52
|
+
assert.deepEqual(drive([text]), { visible: text, reasoning: '' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('does strip a bounded mid-line pair because it is model reasoning markup', () => {
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
drive(['Use <think>hidden</think>this answer']),
|
|
58
|
+
{ visible: 'Use this answer', reasoning: 'hidden' },
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('drops unterminated reasoning content at flush after capturing streamed content', () => {
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
drive(['Visible\n<think>private reasoning with no close']),
|
|
65
|
+
{ visible: 'Visible\n', reasoning: 'private reasoning with no close' },
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('reset clears an interrupted reasoning block and buffered partial tags', () => {
|
|
70
|
+
const scrubber = new StreamingReasoningTagScrubber()
|
|
71
|
+
assert.deepEqual(scrubber.feed('<think>hanging'), { visible: '', reasoning: 'hanging' })
|
|
72
|
+
scrubber.reset()
|
|
73
|
+
assert.deepEqual(scrubber.feed('fresh<'), { visible: 'fresh', reasoning: '' })
|
|
74
|
+
scrubber.reset()
|
|
75
|
+
assert.deepEqual(scrubber.feed('content'), { visible: 'content', reasoning: '' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('processIterationEvents routes split reasoning tags away from visible deltas', async () => {
|
|
79
|
+
async function* eventStream() {
|
|
80
|
+
yield { event: 'on_chat_model_stream', data: { chunk: { content: '<' } } }
|
|
81
|
+
yield { event: 'on_chat_model_stream', data: { chunk: { content: 'think>private' } } }
|
|
82
|
+
yield { event: 'on_chat_model_stream', data: { chunk: { content: ' reasoning</th' } } }
|
|
83
|
+
yield { event: 'on_chat_model_stream', data: { chunk: { content: 'ink>done' } } }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const state = new ChatTurnState()
|
|
87
|
+
const writes: string[] = []
|
|
88
|
+
const outcome = await processIterationEvents({
|
|
89
|
+
eventStream: eventStream(),
|
|
90
|
+
state,
|
|
91
|
+
timers: {
|
|
92
|
+
armIdleWatchdog: () => undefined,
|
|
93
|
+
clearIdleWatchdog: () => undefined,
|
|
94
|
+
clearRequiredToolKickoff: () => undefined,
|
|
95
|
+
} as never,
|
|
96
|
+
loopTracker: {} as never,
|
|
97
|
+
toolEventTracker: {} as never,
|
|
98
|
+
session: { id: 'sess_reasoning_tags', provider: 'openai', model: 'gpt-4o-mini' } as never,
|
|
99
|
+
message: 'answer briefly',
|
|
100
|
+
write: (data: string) => writes.push(data),
|
|
101
|
+
sessionExtensions: [],
|
|
102
|
+
boundedExternalExecutionTask: false,
|
|
103
|
+
toolToExtensionMap: {},
|
|
104
|
+
iterationController: new AbortController(),
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
assert.equal(outcome.iterationText, 'done')
|
|
108
|
+
assert.equal(state.fullText, 'done')
|
|
109
|
+
assert.equal(state.accumulatedThinking, 'private reasoning')
|
|
110
|
+
|
|
111
|
+
const rendered = writes.join('')
|
|
112
|
+
assert.match(rendered, /"t":"thinking"/)
|
|
113
|
+
assert.match(rendered, /"text":"private"/)
|
|
114
|
+
assert.match(rendered, /"text":" reasoning"/)
|
|
115
|
+
assert.doesNotMatch(rendered, /"t":"d"[^\n]*private/)
|
|
116
|
+
assert.doesNotMatch(rendered, /<think>|<\/think>/i)
|
|
117
|
+
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
export interface ReasoningTagScrubResult {
|
|
2
|
+
visible: string
|
|
3
|
+
reasoning: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const TAG_NAMES = [
|
|
7
|
+
'think',
|
|
8
|
+
'thinking',
|
|
9
|
+
'reasoning',
|
|
10
|
+
'thought',
|
|
11
|
+
'reasoning_scratchpad',
|
|
12
|
+
] as const
|
|
13
|
+
|
|
14
|
+
const OPEN_TAGS = TAG_NAMES.map((name) => `<${name}>`)
|
|
15
|
+
const CLOSE_TAGS = TAG_NAMES.map((name) => `</${name}>`)
|
|
16
|
+
const MAX_TAG_LENGTH = Math.max(...OPEN_TAGS.map((tag) => tag.length), ...CLOSE_TAGS.map((tag) => tag.length))
|
|
17
|
+
|
|
18
|
+
function isWhitespace(text: string): boolean {
|
|
19
|
+
for (let i = 0; i < text.length; i++) {
|
|
20
|
+
const code = text.charCodeAt(i)
|
|
21
|
+
if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) return false
|
|
22
|
+
}
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function matchTagAt(lowerText: string, index: number, tags: string[]): number {
|
|
27
|
+
for (const tag of tags) {
|
|
28
|
+
if (lowerText.startsWith(tag, index)) return tag.length
|
|
29
|
+
}
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function findFirstTag(text: string, tags: string[]): { index: number; length: number } {
|
|
34
|
+
const lowerText = text.toLowerCase()
|
|
35
|
+
let bestIndex = -1
|
|
36
|
+
let bestLength = 0
|
|
37
|
+
for (const tag of tags) {
|
|
38
|
+
const index = lowerText.indexOf(tag)
|
|
39
|
+
if (index !== -1 && (bestIndex === -1 || index < bestIndex)) {
|
|
40
|
+
bestIndex = index
|
|
41
|
+
bestLength = tag.length
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { index: bestIndex, length: bestLength }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function maxPartialSuffix(text: string, tags: string[]): number {
|
|
48
|
+
const lowerText = text.toLowerCase()
|
|
49
|
+
const maxLength = Math.min(MAX_TAG_LENGTH - 1, lowerText.length)
|
|
50
|
+
let best = 0
|
|
51
|
+
for (let length = 1; length <= maxLength; length++) {
|
|
52
|
+
const suffix = lowerText.slice(-length)
|
|
53
|
+
if (tags.some((tag) => tag.startsWith(suffix))) best = length
|
|
54
|
+
}
|
|
55
|
+
return best
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stripOrphanCloseTags(text: string): string {
|
|
59
|
+
const lowerText = text.toLowerCase()
|
|
60
|
+
let output = ''
|
|
61
|
+
let index = 0
|
|
62
|
+
while (index < text.length) {
|
|
63
|
+
const closeLength = matchTagAt(lowerText, index, CLOSE_TAGS)
|
|
64
|
+
if (closeLength > 0) {
|
|
65
|
+
index += closeLength
|
|
66
|
+
while (index < text.length && isWhitespace(text[index])) index++
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
output += text[index]
|
|
70
|
+
index++
|
|
71
|
+
}
|
|
72
|
+
return output
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class StreamingReasoningTagScrubber {
|
|
76
|
+
private inBlock = false
|
|
77
|
+
private buffer = ''
|
|
78
|
+
private lastVisibleEndedNewline = true
|
|
79
|
+
|
|
80
|
+
reset(): void {
|
|
81
|
+
this.inBlock = false
|
|
82
|
+
this.buffer = ''
|
|
83
|
+
this.lastVisibleEndedNewline = true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
feed(text: string): ReasoningTagScrubResult {
|
|
87
|
+
if (!text) return { visible: '', reasoning: '' }
|
|
88
|
+
let buffer = this.buffer + text
|
|
89
|
+
this.buffer = ''
|
|
90
|
+
let visible = ''
|
|
91
|
+
let reasoning = ''
|
|
92
|
+
|
|
93
|
+
while (buffer) {
|
|
94
|
+
if (this.inBlock) {
|
|
95
|
+
const close = findFirstTag(buffer, CLOSE_TAGS)
|
|
96
|
+
if (close.index === -1) {
|
|
97
|
+
const held = maxPartialSuffix(buffer, CLOSE_TAGS)
|
|
98
|
+
const captureEnd = held ? buffer.length - held : buffer.length
|
|
99
|
+
reasoning += buffer.slice(0, captureEnd)
|
|
100
|
+
this.buffer = held ? buffer.slice(-held) : ''
|
|
101
|
+
return { visible, reasoning }
|
|
102
|
+
}
|
|
103
|
+
reasoning += buffer.slice(0, close.index)
|
|
104
|
+
buffer = buffer.slice(close.index + close.length)
|
|
105
|
+
this.inBlock = false
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const pair = this.findEarliestClosedPair(buffer)
|
|
110
|
+
const open = this.findOpenAtBoundary(buffer)
|
|
111
|
+
|
|
112
|
+
if (pair && (open.index === -1 || pair.start <= open.index)) {
|
|
113
|
+
const preceding = stripOrphanCloseTags(buffer.slice(0, pair.start))
|
|
114
|
+
if (preceding) {
|
|
115
|
+
visible += preceding
|
|
116
|
+
this.lastVisibleEndedNewline = preceding.endsWith('\n')
|
|
117
|
+
}
|
|
118
|
+
reasoning += buffer.slice(pair.contentStart, pair.contentEnd)
|
|
119
|
+
buffer = buffer.slice(pair.end)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (open.index !== -1) {
|
|
124
|
+
const preceding = stripOrphanCloseTags(buffer.slice(0, open.index))
|
|
125
|
+
if (preceding) {
|
|
126
|
+
visible += preceding
|
|
127
|
+
this.lastVisibleEndedNewline = preceding.endsWith('\n')
|
|
128
|
+
}
|
|
129
|
+
this.inBlock = true
|
|
130
|
+
buffer = buffer.slice(open.index + open.length)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const held = Math.max(maxPartialSuffix(buffer, OPEN_TAGS), maxPartialSuffix(buffer, CLOSE_TAGS))
|
|
135
|
+
const emitText = held ? buffer.slice(0, -held) : buffer
|
|
136
|
+
this.buffer = held ? buffer.slice(-held) : ''
|
|
137
|
+
if (emitText) {
|
|
138
|
+
const cleaned = stripOrphanCloseTags(emitText)
|
|
139
|
+
if (cleaned) {
|
|
140
|
+
visible += cleaned
|
|
141
|
+
this.lastVisibleEndedNewline = cleaned.endsWith('\n')
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { visible, reasoning }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { visible, reasoning }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
flush(): ReasoningTagScrubResult {
|
|
151
|
+
if (this.inBlock) {
|
|
152
|
+
this.inBlock = false
|
|
153
|
+
this.buffer = ''
|
|
154
|
+
return { visible: '', reasoning: '' }
|
|
155
|
+
}
|
|
156
|
+
const tail = stripOrphanCloseTags(this.buffer)
|
|
157
|
+
this.buffer = ''
|
|
158
|
+
if (tail) this.lastVisibleEndedNewline = tail.endsWith('\n')
|
|
159
|
+
return { visible: tail, reasoning: '' }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private findEarliestClosedPair(text: string): {
|
|
163
|
+
start: number
|
|
164
|
+
contentStart: number
|
|
165
|
+
contentEnd: number
|
|
166
|
+
end: number
|
|
167
|
+
} | null {
|
|
168
|
+
const lowerText = text.toLowerCase()
|
|
169
|
+
let best: { start: number; contentStart: number; contentEnd: number; end: number } | null = null
|
|
170
|
+
for (const name of TAG_NAMES) {
|
|
171
|
+
const openTag = `<${name}>`
|
|
172
|
+
const closeTag = `</${name}>`
|
|
173
|
+
let searchFrom = 0
|
|
174
|
+
while (searchFrom < lowerText.length) {
|
|
175
|
+
const start = lowerText.indexOf(openTag, searchFrom)
|
|
176
|
+
if (start === -1) break
|
|
177
|
+
const contentStart = start + openTag.length
|
|
178
|
+
const contentEnd = lowerText.indexOf(closeTag, contentStart)
|
|
179
|
+
if (contentEnd !== -1) {
|
|
180
|
+
const candidate = {
|
|
181
|
+
start,
|
|
182
|
+
contentStart,
|
|
183
|
+
contentEnd,
|
|
184
|
+
end: contentEnd + closeTag.length,
|
|
185
|
+
}
|
|
186
|
+
if (!best || candidate.start < best.start) best = candidate
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
searchFrom = contentStart
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return best
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private findOpenAtBoundary(text: string): { index: number; length: number } {
|
|
196
|
+
const lowerText = text.toLowerCase()
|
|
197
|
+
let best = { index: -1, length: 0 }
|
|
198
|
+
for (const openTag of OPEN_TAGS) {
|
|
199
|
+
let searchFrom = 0
|
|
200
|
+
while (searchFrom < lowerText.length) {
|
|
201
|
+
const index = lowerText.indexOf(openTag, searchFrom)
|
|
202
|
+
if (index === -1) break
|
|
203
|
+
if (this.isBlockBoundary(text.slice(0, index))) {
|
|
204
|
+
if (best.index === -1 || index < best.index) best = { index, length: openTag.length }
|
|
205
|
+
break
|
|
206
|
+
}
|
|
207
|
+
searchFrom = index + openTag.length
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return best
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private isBlockBoundary(preceding: string): boolean {
|
|
214
|
+
if (!preceding) return this.lastVisibleEndedNewline
|
|
215
|
+
const lastNewline = Math.max(preceding.lastIndexOf('\n'), preceding.lastIndexOf('\r'))
|
|
216
|
+
if (lastNewline !== -1) return isWhitespace(preceding.slice(lastNewline + 1))
|
|
217
|
+
return this.lastVisibleEndedNewline && isWhitespace(preceding)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test, { afterEach } from 'node:test'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
|
|
7
|
+
import { getExtensionManager } from './extensions'
|
|
8
|
+
import {
|
|
9
|
+
inspectExtensionLocalFolder,
|
|
10
|
+
listExtensionLocalFolderEntries,
|
|
11
|
+
listExtensionManagedResources,
|
|
12
|
+
reconcileExtensionManagedResources,
|
|
13
|
+
setExtensionLocalFolderConfig,
|
|
14
|
+
} from './extension-managed-resources'
|
|
15
|
+
import { loadAgents, loadSchedules, loadSettings, saveAgents, saveSchedules, saveSettings } from './storage'
|
|
16
|
+
|
|
17
|
+
const originalAgents = loadAgents()
|
|
18
|
+
const originalSchedules = loadSchedules()
|
|
19
|
+
const originalSettings = loadSettings()
|
|
20
|
+
|
|
21
|
+
let seq = 0
|
|
22
|
+
|
|
23
|
+
function extensionId(prefix: string): string {
|
|
24
|
+
seq += 1
|
|
25
|
+
return `${prefix}_${Date.now()}_${seq}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
saveAgents(originalAgents)
|
|
30
|
+
saveSchedules(originalSchedules)
|
|
31
|
+
saveSettings(originalSettings)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('managed resources summary and reconcile create extension-owned agents and schedules', () => {
|
|
35
|
+
const id = extensionId('managed_resources')
|
|
36
|
+
getExtensionManager().registerBuiltin(id, {
|
|
37
|
+
name: 'Managed Resource Fixture',
|
|
38
|
+
description: 'Declares resources for tests.',
|
|
39
|
+
managedResources: {
|
|
40
|
+
agents: [
|
|
41
|
+
{
|
|
42
|
+
agentKey: 'researcher',
|
|
43
|
+
displayName: 'Managed Researcher',
|
|
44
|
+
description: 'Research managed by an extension.',
|
|
45
|
+
systemPrompt: 'Research carefully.',
|
|
46
|
+
provider: 'openai',
|
|
47
|
+
model: 'gpt-4o-mini',
|
|
48
|
+
capabilities: ['research'],
|
|
49
|
+
extensions: ['web'],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
routines: [
|
|
53
|
+
{
|
|
54
|
+
routineKey: 'daily-digest',
|
|
55
|
+
title: 'Daily Digest',
|
|
56
|
+
assigneeRef: { resourceKind: 'agent', resourceKey: 'researcher' },
|
|
57
|
+
taskPrompt: 'Prepare a digest.',
|
|
58
|
+
triggers: [{ kind: 'schedule', cronExpression: '0 9 * * *', timezone: 'UTC' }],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
gatewayPlatforms: [
|
|
62
|
+
{
|
|
63
|
+
platformKey: 'openai-api',
|
|
64
|
+
displayName: 'OpenAI-compatible API',
|
|
65
|
+
transport: 'http',
|
|
66
|
+
endpoint: 'http://127.0.0.1:8642/v1',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
setupChecks: [
|
|
70
|
+
{ checkKey: 'api-key', displayName: 'API key configured', kind: 'env', target: 'OPENAI_API_KEY' },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const before = listExtensionManagedResources().extensions.find((entry) => entry.extensionId === id)
|
|
76
|
+
assert.ok(before)
|
|
77
|
+
assert.equal(before.agents[0].status, 'missing')
|
|
78
|
+
assert.equal(before.schedules[0].status, 'missing_ref')
|
|
79
|
+
|
|
80
|
+
const result = reconcileExtensionManagedResources(id)
|
|
81
|
+
assert.equal(result.createdAgents.length, 1)
|
|
82
|
+
assert.equal(result.createdSchedules.length, 1)
|
|
83
|
+
assert.deepEqual(result.skipped, [])
|
|
84
|
+
|
|
85
|
+
const agents = loadAgents()
|
|
86
|
+
const agent = agents[result.createdAgents[0]]
|
|
87
|
+
assert.equal(agent.name, 'Managed Researcher')
|
|
88
|
+
assert.equal(agent.managedByExtension?.extensionId, id)
|
|
89
|
+
assert.equal(agent.managedByExtension?.resourceKey, 'researcher')
|
|
90
|
+
assert.ok(agent.extensions?.includes(id))
|
|
91
|
+
assert.ok(agent.extensions?.includes('web'))
|
|
92
|
+
|
|
93
|
+
const schedules = loadSchedules()
|
|
94
|
+
const schedule = schedules[result.createdSchedules[0]]
|
|
95
|
+
assert.equal(schedule.name, 'Daily Digest')
|
|
96
|
+
assert.equal(schedule.agentId, agent.id)
|
|
97
|
+
assert.equal(schedule.scheduleType, 'cron')
|
|
98
|
+
assert.equal(schedule.cron, '0 9 * * *')
|
|
99
|
+
assert.equal(schedule.status, 'paused')
|
|
100
|
+
assert.equal(schedule.managedByExtension?.resourceKey, 'daily-digest')
|
|
101
|
+
|
|
102
|
+
const after = listExtensionManagedResources().extensions.find((entry) => entry.extensionId === id)
|
|
103
|
+
assert.equal(after?.agents[0].status, 'resolved')
|
|
104
|
+
assert.equal(after?.schedules[0].status, 'resolved')
|
|
105
|
+
assert.equal(after?.gatewayPlatforms.length, 1)
|
|
106
|
+
assert.equal(after?.setupChecks.length, 1)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('local folder inspection and listing stay inside configured roots', async () => {
|
|
110
|
+
const id = extensionId('managed_folder')
|
|
111
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-managed-folder-'))
|
|
112
|
+
fs.mkdirSync(path.join(tempDir, 'inputs'))
|
|
113
|
+
fs.mkdirSync(path.join(tempDir, 'outputs'))
|
|
114
|
+
fs.writeFileSync(path.join(tempDir, 'inputs', 'brief.txt'), 'hello\n')
|
|
115
|
+
|
|
116
|
+
getExtensionManager().registerBuiltin(id, {
|
|
117
|
+
name: 'Managed Folder Fixture',
|
|
118
|
+
managedResources: {
|
|
119
|
+
localFolders: [
|
|
120
|
+
{
|
|
121
|
+
folderKey: 'workspace',
|
|
122
|
+
displayName: 'Workspace Folder',
|
|
123
|
+
access: 'readWrite',
|
|
124
|
+
requiredDirectories: ['inputs', 'outputs'],
|
|
125
|
+
requiredFiles: ['inputs/brief.txt'],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
setExtensionLocalFolderConfig({
|
|
132
|
+
extensionId: id,
|
|
133
|
+
folderKey: 'workspace',
|
|
134
|
+
path: tempDir,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const status = await inspectExtensionLocalFolder({ extensionId: id, folderKey: 'workspace' })
|
|
138
|
+
assert.equal(status.healthy, true)
|
|
139
|
+
assert.equal(status.readable, true)
|
|
140
|
+
assert.equal(status.writable, true)
|
|
141
|
+
|
|
142
|
+
const listing = await listExtensionLocalFolderEntries({
|
|
143
|
+
extensionId: id,
|
|
144
|
+
folderKey: 'workspace',
|
|
145
|
+
recursive: true,
|
|
146
|
+
})
|
|
147
|
+
assert.ok(listing.entries.some((entry) => entry.path === 'inputs/brief.txt' && entry.kind === 'file'))
|
|
148
|
+
|
|
149
|
+
await assert.rejects(
|
|
150
|
+
() => listExtensionLocalFolderEntries({
|
|
151
|
+
extensionId: id,
|
|
152
|
+
folderKey: 'workspace',
|
|
153
|
+
relativePath: '../outside',
|
|
154
|
+
}),
|
|
155
|
+
/inside the configured root|traversal/,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
159
|
+
})
|