@swarmclawai/swarmclaw 1.9.1 → 1.9.2
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 +10 -0
- package/package.json +3 -3
- package/scripts/ensure-sandbox-browser-image.mjs +12 -2
- 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/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/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/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/misc.ts +1 -1
package/README.md
CHANGED
|
@@ -399,6 +399,16 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
+
### v1.9.2 Highlights
|
|
403
|
+
|
|
404
|
+
Bundled competitor-parity release: Hermes-style reasoning hygiene, deterministic delegation routing, Mission Control task workflow polish, OpenClaw export hardening, and Paperclip-style timeout hygiene.
|
|
405
|
+
|
|
406
|
+
- **Stateful reasoning tag scrubber.** String-streamed `<think>`, `<thinking>`, `<reasoning>`, `<thought>`, and `<REASONING_SCRATCHPAD>` blocks are removed across split deltas and routed into SwarmClaw's thinking stream instead of leaking into visible answers.
|
|
407
|
+
- **Deterministic delegation profiles.** `manage_tasks` now accepts explicit `workType` and `requiredCapabilities` routing hints, returns a stable `routeKey`, and can auto-assign unowned work without a classifier call when the profile is explicit.
|
|
408
|
+
- **Assignment workflow transitions.** Newly assigned backlog/triage/todo tasks move into the `in_progress` workflow lane without changing their runtime status or queueing execution.
|
|
409
|
+
- **Knowledge hygiene pruning.** Archived or superseded knowledge sources can now be pruned after a retention window, with prune actions recorded in the hygiene summary.
|
|
410
|
+
- **Collision-safe exports and timeout hardening.** Portability exports support timestamped attachment filenames, the sandbox browser image build has a configurable timeout, and release notes now carry the macOS quarantine workaround for ad-hoc signed desktop builds.
|
|
411
|
+
|
|
402
412
|
### v1.9.1 Highlights
|
|
403
413
|
|
|
404
414
|
Task execution workspace release: the first Paperclip-style work-control slice for task-scoped workspaces, preview handoffs, and liveness evidence.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.2",
|
|
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",
|
|
@@ -84,10 +84,10 @@
|
|
|
84
84
|
"lint:baseline": "node ./scripts/lint-baseline.mjs check",
|
|
85
85
|
"lint:baseline:update": "node ./scripts/lint-baseline.mjs update",
|
|
86
86
|
"cli": "node ./bin/swarmclaw.js",
|
|
87
|
-
"test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
|
|
87
|
+
"test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
|
|
88
88
|
"test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
|
|
89
89
|
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
|
|
90
|
-
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/session-tools/execute.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
|
|
90
|
+
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
|
|
91
91
|
"test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
|
|
92
92
|
"test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
|
|
93
93
|
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
|
|
@@ -10,6 +10,7 @@ const args = new Set(process.argv.slice(2))
|
|
|
10
10
|
const quiet = args.has('--quiet')
|
|
11
11
|
const required = args.has('--required')
|
|
12
12
|
const image = process.env.SWARMCLAW_SANDBOX_BROWSER_IMAGE || 'swarmclaw-sandbox-browser:bookworm-slim'
|
|
13
|
+
const DEFAULT_BUILD_TIMEOUT_MS = 20 * 60 * 1000
|
|
13
14
|
const SOURCE_LABEL = 'swarmclaw.sandboxBrowserSourceHash'
|
|
14
15
|
|
|
15
16
|
function log(message) {
|
|
@@ -29,6 +30,11 @@ function run(command, commandArgs, options = {}) {
|
|
|
29
30
|
})
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
function readPositiveInteger(value, fallback) {
|
|
34
|
+
const parsed = Number.parseInt(String(value || ''), 10)
|
|
35
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
function commandExists(name) {
|
|
33
39
|
const lookup = process.platform === 'win32' ? 'where' : 'which'
|
|
34
40
|
const result = run(lookup, [name])
|
|
@@ -56,6 +62,7 @@ function readImageLabel(name, label) {
|
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
function buildImage(sourceHash) {
|
|
65
|
+
const timeoutMs = readPositiveInteger(process.env.SWARMCLAW_SANDBOX_BROWSER_BUILD_TIMEOUT_MS, DEFAULT_BUILD_TIMEOUT_MS)
|
|
59
66
|
log(`Building sandbox browser image ${image}...`)
|
|
60
67
|
const result = spawnSync(
|
|
61
68
|
'docker',
|
|
@@ -69,13 +76,16 @@ function buildImage(sourceHash) {
|
|
|
69
76
|
{
|
|
70
77
|
cwd,
|
|
71
78
|
stdio: 'inherit',
|
|
79
|
+
timeout: timeoutMs,
|
|
72
80
|
},
|
|
73
81
|
)
|
|
74
82
|
if (result.error || (result.status ?? 1) !== 0) {
|
|
83
|
+
const timedOut = result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM'
|
|
84
|
+
const detail = timedOut ? ` Build timed out after ${timeoutMs}ms.` : ''
|
|
75
85
|
if (required) {
|
|
76
|
-
fail(`Failed to build sandbox browser image ${image}
|
|
86
|
+
fail(`Failed to build sandbox browser image ${image}.${detail}`, result.status ?? 1)
|
|
77
87
|
}
|
|
78
|
-
log(`Skipping sandbox browser image after build failure
|
|
88
|
+
log(`Skipping sandbox browser image after build failure.${detail}`)
|
|
79
89
|
return false
|
|
80
90
|
}
|
|
81
91
|
log(`Sandbox browser image ready: ${image}`)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import {
|
|
3
3
|
getKnowledgeHygieneSummary,
|
|
4
|
+
pruneArchivedKnowledgeSources,
|
|
4
5
|
runKnowledgeHygieneMaintenance,
|
|
5
6
|
} from '@/lib/server/knowledge-sources'
|
|
6
7
|
|
|
@@ -8,6 +9,23 @@ export async function GET() {
|
|
|
8
9
|
return NextResponse.json(await getKnowledgeHygieneSummary())
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export async function POST() {
|
|
12
|
+
export async function POST(req: Request) {
|
|
13
|
+
let body: Record<string, unknown> | null = null
|
|
14
|
+
if ((req.headers.get('content-type') || '').includes('application/json')) {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = await req.json()
|
|
17
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) body = parsed as Record<string, unknown>
|
|
18
|
+
} catch {
|
|
19
|
+
body = null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (body?.action === 'prune') {
|
|
24
|
+
const olderThanDays = typeof body.olderThanDays === 'number' ? body.olderThanDays : null
|
|
25
|
+
const result = await pruneArchivedKnowledgeSources({ olderThanDays })
|
|
26
|
+
const summary = await getKnowledgeHygieneSummary()
|
|
27
|
+
return NextResponse.json({ ...summary, prune: result })
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
return NextResponse.json(await runKnowledgeHygieneMaintenance())
|
|
13
31
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { GET } from './route'
|
|
5
|
+
import { buildPortableExportFilename } from '@/lib/server/portability/export'
|
|
6
|
+
|
|
7
|
+
describe('GET /api/portability/export', () => {
|
|
8
|
+
it('returns a collision-resistant attachment filename for downloads', async () => {
|
|
9
|
+
const response = await GET(new Request('http://local/api/portability/export?download=true'))
|
|
10
|
+
assert.equal(response.status, 200)
|
|
11
|
+
assert.equal(response.headers.get('content-type'), 'application/json; charset=utf-8')
|
|
12
|
+
const disposition = response.headers.get('content-disposition') || ''
|
|
13
|
+
assert.match(disposition, /^attachment; filename="swarmclaw-export-\d{8}-\d{6}\d{3}Z\.json"$/)
|
|
14
|
+
const body = await response.json()
|
|
15
|
+
assert.equal(disposition, `attachment; filename="${buildPortableExportFilename(body)}"`)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { exportConfig } from '@/lib/server/portability/export'
|
|
2
|
+
import { buildPortableExportFilename, exportConfig } from '@/lib/server/portability/export'
|
|
3
3
|
export const dynamic = 'force-dynamic'
|
|
4
4
|
|
|
5
|
-
export async function GET() {
|
|
5
|
+
export async function GET(req: Request) {
|
|
6
6
|
const manifest = exportConfig()
|
|
7
|
+
const { searchParams } = new URL(req.url)
|
|
8
|
+
if (searchParams.get('download') === 'true') {
|
|
9
|
+
return new NextResponse(JSON.stringify(manifest, null, 2), {
|
|
10
|
+
headers: {
|
|
11
|
+
'content-type': 'application/json; charset=utf-8',
|
|
12
|
+
'content-disposition': `attachment; filename="${buildPortableExportFilename(manifest)}"`,
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
}
|
|
7
16
|
return NextResponse.json(manifest)
|
|
8
17
|
}
|
|
@@ -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
|
+
}
|
|
@@ -259,3 +259,48 @@ test('runKnowledgeHygieneMaintenance keeps same-content sources separate when vi
|
|
|
259
259
|
assert.equal(output.agent1Hits, 2)
|
|
260
260
|
assert.equal(output.agent2Hits, 1)
|
|
261
261
|
})
|
|
262
|
+
|
|
263
|
+
test('pruneArchivedKnowledgeSources deletes old archived sources and records prune actions', () => {
|
|
264
|
+
const output = runWithTempDataDir<{
|
|
265
|
+
pruned: number
|
|
266
|
+
sourceStillExists: boolean
|
|
267
|
+
hitCount: number
|
|
268
|
+
actionKind: string | null
|
|
269
|
+
}>(`
|
|
270
|
+
const knowledgeMod = await import('./src/lib/server/knowledge-sources.ts')
|
|
271
|
+
const storageMod = await import('./src/lib/server/storage.ts')
|
|
272
|
+
const knowledge = knowledgeMod.default || knowledgeMod
|
|
273
|
+
const storage = storageMod.default || storageMod
|
|
274
|
+
|
|
275
|
+
const source = await knowledge.createKnowledgeSource({
|
|
276
|
+
kind: 'manual',
|
|
277
|
+
title: 'Old Archived Notes',
|
|
278
|
+
content: 'prune candidate payload',
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
await knowledge.archiveKnowledgeSource(source.source.id, { reason: 'obsolete' })
|
|
282
|
+
const oldTimestamp = Date.now() - 60 * 24 * 60 * 60 * 1000
|
|
283
|
+
storage.patchKnowledgeSource(source.source.id, (current) => current ? {
|
|
284
|
+
...current,
|
|
285
|
+
archivedAt: oldTimestamp,
|
|
286
|
+
maintenanceUpdatedAt: oldTimestamp,
|
|
287
|
+
updatedAt: oldTimestamp,
|
|
288
|
+
} : null)
|
|
289
|
+
|
|
290
|
+
const result = await knowledge.pruneArchivedKnowledgeSources({ olderThanDays: 30 })
|
|
291
|
+
const summary = await knowledge.getKnowledgeHygieneSummary()
|
|
292
|
+
const hits = await knowledge.searchKnowledgeHits({ query: 'candidate', includeArchived: true })
|
|
293
|
+
|
|
294
|
+
console.log(JSON.stringify({
|
|
295
|
+
pruned: result.pruned,
|
|
296
|
+
sourceStillExists: Boolean(storage.loadKnowledgeSource(source.source.id)),
|
|
297
|
+
hitCount: hits.length,
|
|
298
|
+
actionKind: summary.recentActions.find((action) => action.sourceId === source.source.id)?.kind || null,
|
|
299
|
+
}))
|
|
300
|
+
`, { prefix: 'swarmclaw-knowledge-prune-' })
|
|
301
|
+
|
|
302
|
+
assert.equal(output.pruned, 1)
|
|
303
|
+
assert.equal(output.sourceStillExists, false)
|
|
304
|
+
assert.equal(output.hitCount, 0)
|
|
305
|
+
assert.equal(output.actionKind, 'prune')
|
|
306
|
+
})
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { onNextIdleWindow } from '@/lib/server/runtime/idle-window'
|
|
32
32
|
|
|
33
33
|
const KNOWLEDGE_STALE_AFTER_MS = 1000 * 60 * 60 * 24 * 14
|
|
34
|
+
const DEFAULT_PRUNE_ARCHIVED_AFTER_DAYS = 30
|
|
34
35
|
const CHUNK_TARGET_CHARS = 2200
|
|
35
36
|
const CHUNK_OVERLAP_CHARS = 320
|
|
36
37
|
const MAX_KNOWLEDGE_SCAN = 10_000
|
|
@@ -1049,6 +1050,38 @@ export async function supersedeKnowledgeSource(
|
|
|
1049
1050
|
return getKnowledgeSourceDetail(updated.id)
|
|
1050
1051
|
}
|
|
1051
1052
|
|
|
1053
|
+
export async function pruneArchivedKnowledgeSources(input?: {
|
|
1054
|
+
olderThanDays?: number | null
|
|
1055
|
+
now?: number
|
|
1056
|
+
}): Promise<{ pruned: number; sourceIds: string[] }> {
|
|
1057
|
+
await ensureLegacyKnowledgeBackfill()
|
|
1058
|
+
const now = typeof input?.now === 'number' && Number.isFinite(input.now) ? input.now : Date.now()
|
|
1059
|
+
const olderThanDays = typeof input?.olderThanDays === 'number' && Number.isFinite(input.olderThanDays)
|
|
1060
|
+
? Math.max(1, Math.trunc(input.olderThanDays))
|
|
1061
|
+
: DEFAULT_PRUNE_ARCHIVED_AFTER_DAYS
|
|
1062
|
+
const cutoff = now - olderThanDays * 24 * 60 * 60 * 1000
|
|
1063
|
+
const sourceIds: string[] = []
|
|
1064
|
+
|
|
1065
|
+
for (const source of listStoredSources()) {
|
|
1066
|
+
if (!sourceIsArchived(source) && !sourceIsSuperseded(source)) continue
|
|
1067
|
+
const lifecycleAt = source.archivedAt || source.maintenanceUpdatedAt || source.updatedAt
|
|
1068
|
+
if (lifecycleAt > cutoff) continue
|
|
1069
|
+
const title = source.title
|
|
1070
|
+
const removed = await deleteKnowledgeSource(source.id)
|
|
1071
|
+
if (!removed) continue
|
|
1072
|
+
sourceIds.push(source.id)
|
|
1073
|
+
recordMaintenanceAction({
|
|
1074
|
+
kind: 'prune',
|
|
1075
|
+
sourceId: source.id,
|
|
1076
|
+
relatedSourceId: source.duplicateOfSourceId || source.supersededBySourceId || null,
|
|
1077
|
+
summary: `Pruned ${title}`,
|
|
1078
|
+
createdAt: now,
|
|
1079
|
+
})
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
return { pruned: sourceIds.length, sourceIds }
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1052
1085
|
function sameSourceOrigin(left: KnowledgeSource, right: KnowledgeSource): boolean {
|
|
1053
1086
|
if (left.id === right.id) return false
|
|
1054
1087
|
if (left.sourceUrl && right.sourceUrl) return left.sourceUrl === right.sourceUrl
|
|
@@ -34,6 +34,16 @@ export interface PortableManifest {
|
|
|
34
34
|
extensions?: PortableExtensionRef[]
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export function buildPortableExportFilename(manifest: Pick<PortableManifest, 'exportedAt'> = { exportedAt: new Date().toISOString() }): string {
|
|
38
|
+
const safeStamp = manifest.exportedAt
|
|
39
|
+
.replaceAll(':', '')
|
|
40
|
+
.replaceAll('.', '')
|
|
41
|
+
.replaceAll('-', '')
|
|
42
|
+
.replace('T', '-')
|
|
43
|
+
.replace('Z', 'Z')
|
|
44
|
+
return `swarmclaw-export-${safeStamp}.json`
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
export type PortableAgent = Omit<Agent,
|
|
38
48
|
| 'id' | 'credentialId' | 'fallbackCredentialIds' | 'apiEndpoint'
|
|
39
49
|
| 'threadSessionId' | 'lastUsedAt' | 'totalCost' | 'trashedAt'
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
buildDelegationTaskProfile,
|
|
44
44
|
formatDelegationRationale,
|
|
45
45
|
resolveDelegationAdvisory,
|
|
46
|
+
type DelegationWorkType,
|
|
46
47
|
} from '@/lib/server/agents/delegation-advisory'
|
|
47
48
|
import type { ToolBuildContext } from './context'
|
|
48
49
|
import { normalizeToolInputArgs } from './normalize-tool-args'
|
|
@@ -102,6 +103,17 @@ function buildTaskDelegationText(parsed: Record<string, unknown>): string {
|
|
|
102
103
|
return [title, description].filter(Boolean).join('\n\n').trim()
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
function normalizeDelegationWorkType(value: unknown): DelegationWorkType | null {
|
|
107
|
+
return value === 'coding'
|
|
108
|
+
|| value === 'research'
|
|
109
|
+
|| value === 'writing'
|
|
110
|
+
|| value === 'review'
|
|
111
|
+
|| value === 'operations'
|
|
112
|
+
|| value === 'general'
|
|
113
|
+
? value
|
|
114
|
+
: null
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
async function resolveManagedTaskDelegation(params: {
|
|
106
118
|
parsed: Record<string, unknown>
|
|
107
119
|
agents: ReturnType<typeof loadAgents>
|
|
@@ -122,9 +134,11 @@ async function resolveManagedTaskDelegation(params: {
|
|
|
122
134
|
return { assignedAgentId: params.assignedAgentId, advisory: null }
|
|
123
135
|
}
|
|
124
136
|
|
|
137
|
+
const decisionStartedAt = Date.now()
|
|
125
138
|
const explicitCapabilities = normalizeStringList(params.parsed.requiredCapabilities)
|
|
139
|
+
const explicitWorkType = normalizeDelegationWorkType(params.parsed.workType)
|
|
126
140
|
const classificationText = buildTaskDelegationText(params.parsed)
|
|
127
|
-
const classification = (!explicitCapabilities.length && classificationText && params.ctx?.sessionId)
|
|
141
|
+
const classification = (!explicitCapabilities.length && !explicitWorkType && classificationText && params.ctx?.sessionId)
|
|
128
142
|
? await classifyMessage({
|
|
129
143
|
sessionId: params.ctx.sessionId,
|
|
130
144
|
agentId: currentAgentId,
|
|
@@ -134,6 +148,7 @@ async function resolveManagedTaskDelegation(params: {
|
|
|
134
148
|
|
|
135
149
|
const profile = buildDelegationTaskProfile({
|
|
136
150
|
classification,
|
|
151
|
+
workType: explicitWorkType,
|
|
137
152
|
requiredCapabilities: explicitCapabilities,
|
|
138
153
|
})
|
|
139
154
|
if (!profile.substantial) {
|
|
@@ -167,10 +182,18 @@ async function resolveManagedTaskDelegation(params: {
|
|
|
167
182
|
advisory: {
|
|
168
183
|
recommendedAgentId: recommended.agentId,
|
|
169
184
|
recommendedAgentName: recommended.agentName,
|
|
185
|
+
routeKey: recommended.routeKey,
|
|
170
186
|
rationale: formatDelegationRationale(recommended),
|
|
171
187
|
workType: profile.workType,
|
|
172
188
|
requiredCapabilities: profile.requiredCapabilities,
|
|
173
189
|
autoAssigned,
|
|
190
|
+
routingMode: explicitCapabilities.length || explicitWorkType
|
|
191
|
+
? 'deterministic'
|
|
192
|
+
: classification
|
|
193
|
+
? 'classified'
|
|
194
|
+
: 'default',
|
|
195
|
+
decisionLatencyMs: Date.now() - decisionStartedAt,
|
|
196
|
+
score: Number(recommended.score.toFixed(3)),
|
|
174
197
|
},
|
|
175
198
|
}
|
|
176
199
|
}
|
|
@@ -465,7 +488,7 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
|
|
|
465
488
|
}
|
|
466
489
|
description += '\n\nCreate/update calls accept either `data` as a JSON string or direct top-level fields like `title`, `description`, `status`, `agentId`, and `projectId`.'
|
|
467
490
|
if (canAssignOtherAgents) {
|
|
468
|
-
description += '\n\nWhen you omit an assignee, the runtime may auto-assign the task to a materially better-fit teammate based on `requiredCapabilities` or the classified work type. If you set an explicit assignee, it is respected in v1, but the response may include `delegationAdvisory` when another teammate is a better fit.'
|
|
491
|
+
description += '\n\nWhen you omit an assignee, the runtime may auto-assign the task to a materially better-fit teammate based on `requiredCapabilities`, explicit `workType`, or the classified work type. Use `workType:"coding"|"research"|"writing"|"review"|"operations"|"general"` for deterministic routing without a classifier call. If you set an explicit assignee, it is respected in v1, but the response may include `delegationAdvisory` when another teammate is a better fit.'
|
|
469
492
|
}
|
|
470
493
|
description += '\n\nFor follow-up work, set `continueFromTaskId` (or `followUpToTaskId`) to a prior task ID. The new task will inherit the predecessor\'s project/agent/session context, block on that task by default, and reuse its execution session when possible.'
|
|
471
494
|
if (ctx?.projectId) {
|
|
@@ -172,7 +172,7 @@ describe('manage_tasks tool', () => {
|
|
|
172
172
|
action: 'create',
|
|
173
173
|
title: 'Implement API integration',
|
|
174
174
|
description: 'Build the API client and fix the failing tests.',
|
|
175
|
-
|
|
175
|
+
workType: 'coding',
|
|
176
176
|
status: 'backlog',
|
|
177
177
|
})
|
|
178
178
|
|
|
@@ -185,7 +185,11 @@ describe('manage_tasks tool', () => {
|
|
|
185
185
|
assert.equal(output.stored.agentId, 'builder')
|
|
186
186
|
assert.equal(output.response.delegationAdvisory.autoAssigned, true)
|
|
187
187
|
assert.equal(output.response.delegationAdvisory.recommendedAgentId, 'builder')
|
|
188
|
-
assert.deepEqual(output.response.delegationAdvisory.requiredCapabilities, ['coding'])
|
|
188
|
+
assert.deepEqual(output.response.delegationAdvisory.requiredCapabilities, ['coding', 'implementation', 'debugging'])
|
|
189
|
+
assert.equal(output.response.delegationAdvisory.routingMode, 'deterministic')
|
|
190
|
+
assert.match(output.response.delegationAdvisory.routeKey, /^coding:coding,debugging,implementation:builder$/)
|
|
191
|
+
assert.equal(typeof output.response.delegationAdvisory.decisionLatencyMs, 'number')
|
|
192
|
+
assert.equal(output.stored.workflowStateId, 'in_progress')
|
|
189
193
|
})
|
|
190
194
|
|
|
191
195
|
it('keeps an explicit assignee but returns delegation advisory when another teammate is a better fit', () => {
|
|
@@ -262,5 +266,6 @@ describe('manage_tasks tool', () => {
|
|
|
262
266
|
assert.equal(output.stored.agentId, 'researcher')
|
|
263
267
|
assert.equal(output.response.delegationAdvisory.autoAssigned, false)
|
|
264
268
|
assert.equal(output.response.delegationAdvisory.recommendedAgentId, 'builder')
|
|
269
|
+
assert.equal(output.response.delegationAdvisory.routingMode, 'deterministic')
|
|
265
270
|
})
|
|
266
271
|
})
|
|
@@ -28,7 +28,11 @@ import {
|
|
|
28
28
|
type PrepareTaskExecutionWorkspaceOptions,
|
|
29
29
|
} from '@/lib/server/tasks/task-execution-workspace'
|
|
30
30
|
import { resolveTaskAgentFromDescription } from '@/lib/server/tasks/task-mention'
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
applyTaskPatch,
|
|
33
|
+
prepareTaskCreation,
|
|
34
|
+
resolveAssignmentWorkflowStateTransition,
|
|
35
|
+
} from '@/lib/server/tasks/task-service'
|
|
32
36
|
import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
|
|
33
37
|
import { queueSwarmFeedTaskCompletionWake } from '@/lib/server/swarmfeed-runtime'
|
|
34
38
|
import { notify } from '@/lib/server/ws-hub'
|
|
@@ -427,12 +431,25 @@ export function bulkUpdateTasksFromRoute(body: Record<string, unknown>): Service
|
|
|
427
431
|
}
|
|
428
432
|
}
|
|
429
433
|
if ('agentId' in body) {
|
|
434
|
+
const previousAgentId = tasks[id].agentId
|
|
435
|
+
const previousWorkflowStateId = tasks[id].workflowStateId || null
|
|
430
436
|
tasks[id].agentId = body.agentId === null ? '' : String(body.agentId)
|
|
437
|
+
const workflowTransition = resolveAssignmentWorkflowStateTransition({
|
|
438
|
+
previousAgentId,
|
|
439
|
+
nextAgentId: tasks[id].agentId,
|
|
440
|
+
previousWorkflowStateId,
|
|
441
|
+
explicitWorkflowState: Object.prototype.hasOwnProperty.call(body, 'workflowStateId'),
|
|
442
|
+
})
|
|
443
|
+
if (workflowTransition) tasks[id].workflowStateId = workflowTransition
|
|
431
444
|
}
|
|
432
445
|
if ('projectId' in body) {
|
|
433
446
|
if (body.projectId === null) delete tasks[id].projectId
|
|
434
447
|
else tasks[id].projectId = String(body.projectId)
|
|
435
448
|
}
|
|
449
|
+
if ('workflowStateId' in body) {
|
|
450
|
+
if (body.workflowStateId === null) delete tasks[id].workflowStateId
|
|
451
|
+
else tasks[id].workflowStateId = String(body.workflowStateId)
|
|
452
|
+
}
|
|
436
453
|
tasks[id].updatedAt = Date.now()
|
|
437
454
|
updated += 1
|
|
438
455
|
results.push(id)
|
|
@@ -3,8 +3,11 @@ import { describe, it } from 'node:test'
|
|
|
3
3
|
|
|
4
4
|
import { computeTaskFingerprint } from '@/lib/task-dedupe'
|
|
5
5
|
import type { BoardTask } from '@/types'
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
applyTaskPatch,
|
|
8
|
+
prepareTaskCreation,
|
|
9
|
+
resolveAssignmentWorkflowStateTransition,
|
|
10
|
+
} from '@/lib/server/tasks/task-service'
|
|
8
11
|
|
|
9
12
|
function makeTask(overrides: Partial<BoardTask> = {}): BoardTask {
|
|
10
13
|
return {
|
|
@@ -106,3 +109,58 @@ describe('task service helpers', () => {
|
|
|
106
109
|
assert.equal(task.status, 'queued')
|
|
107
110
|
})
|
|
108
111
|
})
|
|
112
|
+
|
|
113
|
+
describe('task-service assignment workflow transitions', () => {
|
|
114
|
+
it('moves newly assigned backlog workflow tasks to in_progress without queueing runtime work', () => {
|
|
115
|
+
const task = makeTask({ agentId: '', workflowStateId: 'backlog' })
|
|
116
|
+
applyTaskPatch({
|
|
117
|
+
task,
|
|
118
|
+
patch: { agentId: 'agent-builder' },
|
|
119
|
+
now: 100,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
assert.equal(task.agentId, 'agent-builder')
|
|
123
|
+
assert.equal(task.status, 'backlog')
|
|
124
|
+
assert.equal(task.workflowStateId, 'in_progress')
|
|
125
|
+
assert.equal(task.updatedAt, 100)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('preserves explicit workflow state patches', () => {
|
|
129
|
+
const task = makeTask({ workflowStateId: 'todo' })
|
|
130
|
+
applyTaskPatch({
|
|
131
|
+
task,
|
|
132
|
+
patch: { agentId: 'agent-builder', workflowStateId: 'needs_review' },
|
|
133
|
+
now: 100,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
assert.equal(task.workflowStateId, 'needs_review')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('seeds assigned task creation into the in_progress workflow lane', () => {
|
|
140
|
+
const prepared = prepareTaskCreation({
|
|
141
|
+
input: {
|
|
142
|
+
title: 'Build the client',
|
|
143
|
+
description: '',
|
|
144
|
+
agentId: 'builder',
|
|
145
|
+
},
|
|
146
|
+
tasks: {},
|
|
147
|
+
now: 200,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
assert.equal(prepared.ok, true)
|
|
151
|
+
if (prepared.ok) {
|
|
152
|
+
assert.equal(prepared.task.status, 'backlog')
|
|
153
|
+
assert.equal(prepared.task.workflowStateId, 'in_progress')
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('leaves already-started workflow states alone', () => {
|
|
158
|
+
const next = resolveAssignmentWorkflowStateTransition({
|
|
159
|
+
previousAgentId: '',
|
|
160
|
+
nextAgentId: 'agent-builder',
|
|
161
|
+
previousWorkflowStateId: 'needs_review',
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
assert.equal(next, null)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -20,6 +20,28 @@ const TASK_STATUS_VALUES = new Set([
|
|
|
20
20
|
'archived',
|
|
21
21
|
])
|
|
22
22
|
|
|
23
|
+
const ASSIGNMENT_START_WORKFLOW_STATES = new Set(['triage', 'backlog', 'todo'])
|
|
24
|
+
|
|
25
|
+
function hasOwn(record: Record<string, unknown>, key: string): boolean {
|
|
26
|
+
return Object.prototype.hasOwnProperty.call(record, key)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveAssignmentWorkflowStateTransition(params: {
|
|
30
|
+
previousAgentId?: string | null
|
|
31
|
+
nextAgentId?: string | null
|
|
32
|
+
previousWorkflowStateId?: string | null
|
|
33
|
+
explicitWorkflowState?: boolean
|
|
34
|
+
}): string | null {
|
|
35
|
+
if (params.explicitWorkflowState) return null
|
|
36
|
+
const previousAgentId = typeof params.previousAgentId === 'string' ? params.previousAgentId.trim() : ''
|
|
37
|
+
const nextAgentId = typeof params.nextAgentId === 'string' ? params.nextAgentId.trim() : ''
|
|
38
|
+
if (!nextAgentId || nextAgentId === previousAgentId) return null
|
|
39
|
+
const currentWorkflow = typeof params.previousWorkflowStateId === 'string' && params.previousWorkflowStateId.trim()
|
|
40
|
+
? params.previousWorkflowStateId.trim()
|
|
41
|
+
: 'backlog'
|
|
42
|
+
return ASSIGNMENT_START_WORKFLOW_STATES.has(currentWorkflow) ? 'in_progress' : null
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
export function deriveTaskTitle(input: { title?: unknown; description?: unknown }): string {
|
|
24
46
|
const explicit = typeof input.title === 'string' ? input.title.replace(/\s+/g, ' ').trim() : ''
|
|
25
47
|
if (explicit && !/^untitled task$/i.test(explicit)) return explicit.slice(0, 120)
|
|
@@ -157,6 +179,7 @@ export type PrepareTaskCreationResult =
|
|
|
157
179
|
|
|
158
180
|
export function prepareTaskCreation(options: PrepareTaskCreationOptions): PrepareTaskCreationResult {
|
|
159
181
|
const seed = options.seed ? { ...options.seed } : {}
|
|
182
|
+
delete seed.workType
|
|
160
183
|
const explicitTitle = typeof options.input.title === 'string' ? options.input.title.trim() : ''
|
|
161
184
|
const derivedTitle = deriveTaskTitle(options.input)
|
|
162
185
|
const nextTitle = options.deriveTitleFromDescription
|
|
@@ -194,6 +217,9 @@ export function prepareTaskCreation(options: PrepareTaskCreationOptions): Prepar
|
|
|
194
217
|
qualityGate,
|
|
195
218
|
},
|
|
196
219
|
})
|
|
220
|
+
if (!task.workflowStateId && task.agentId) {
|
|
221
|
+
task.workflowStateId = 'in_progress'
|
|
222
|
+
}
|
|
197
223
|
task.fingerprint = computeTaskFingerprint(task.title || 'Untitled Task', task.agentId || '')
|
|
198
224
|
|
|
199
225
|
const duplicate = task.fingerprint
|
|
@@ -227,6 +253,8 @@ export interface ApplyTaskPatchOptions {
|
|
|
227
253
|
|
|
228
254
|
export function applyTaskPatch(options: ApplyTaskPatchOptions): BoardTask {
|
|
229
255
|
const nextPatch = { ...options.patch }
|
|
256
|
+
const previousAgentId = options.task.agentId
|
|
257
|
+
const previousWorkflowStateId = options.task.workflowStateId || null
|
|
230
258
|
if (Object.prototype.hasOwnProperty.call(nextPatch, 'status')) {
|
|
231
259
|
const normalized = normalizeTaskStatusInput(nextPatch.status, options.task.status)
|
|
232
260
|
if (normalized) nextPatch.status = normalized
|
|
@@ -240,6 +268,13 @@ export function applyTaskPatch(options: ApplyTaskPatchOptions): BoardTask {
|
|
|
240
268
|
|
|
241
269
|
Object.assign(options.task, nextPatch, { updatedAt: options.now })
|
|
242
270
|
if (options.clearProjectIdWhenNull && nextPatch.projectId === null) delete options.task.projectId
|
|
271
|
+
const workflowTransition = resolveAssignmentWorkflowStateTransition({
|
|
272
|
+
previousAgentId,
|
|
273
|
+
nextAgentId: options.task.agentId,
|
|
274
|
+
previousWorkflowStateId,
|
|
275
|
+
explicitWorkflowState: hasOwn(nextPatch, 'workflowStateId'),
|
|
276
|
+
})
|
|
277
|
+
if (workflowTransition) options.task.workflowStateId = workflowTransition
|
|
243
278
|
|
|
244
279
|
if (options.task.status === 'completed') {
|
|
245
280
|
const { validation } = refreshTaskCompletionValidation(options.task, options.settings)
|
|
@@ -200,6 +200,8 @@ export const TaskCreateSchema = z.object({
|
|
|
200
200
|
retryBackoffSec: z.number().optional(),
|
|
201
201
|
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
|
202
202
|
dueAt: z.number().nullable().optional(),
|
|
203
|
+
workflowStateId: z.string().nullable().optional(),
|
|
204
|
+
requiredCapabilities: z.array(z.string()).optional(),
|
|
203
205
|
provisionWorkspace: z.boolean().optional(),
|
|
204
206
|
previewLinks: z.array(z.object({
|
|
205
207
|
id: z.string().optional(),
|
package/src/types/misc.ts
CHANGED
|
@@ -486,7 +486,7 @@ export interface MemoryEntry {
|
|
|
486
486
|
|
|
487
487
|
export type KnowledgeSourceKind = 'manual' | 'file' | 'url'
|
|
488
488
|
export type KnowledgeSyncStatus = 'ready' | 'syncing' | 'error'
|
|
489
|
-
export type KnowledgeHygieneActionKind = 'sync' | 'reindex' | 'archive' | 'restore' | 'supersede'
|
|
489
|
+
export type KnowledgeHygieneActionKind = 'sync' | 'reindex' | 'archive' | 'restore' | 'supersede' | 'prune'
|
|
490
490
|
export type KnowledgeHygieneFindingKind = 'stale' | 'duplicate' | 'overlap' | 'broken' | 'archived' | 'superseded'
|
|
491
491
|
|
|
492
492
|
export interface KnowledgeCitation {
|