@swarmclawai/swarmclaw 1.5.53 → 1.5.55
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 +17 -3
- package/package.json +2 -2
- package/src/app/api/agents/[id]/route.ts +14 -2
- package/src/app/api/agents/agents-route.test.ts +65 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +5 -3
- package/src/app/api/chatrooms/route.ts +3 -0
- package/src/app/api/missions/[id]/control/route.ts +21 -0
- package/src/app/api/missions/templates/[id]/instantiate/route.ts +64 -0
- package/src/app/api/missions/templates/route.ts +8 -0
- package/src/app/api/tasks/[id]/route.ts +11 -1
- package/src/app/api/tasks/tasks-route.test.ts +81 -0
- package/src/app/api/webhooks/[id]/route.ts +18 -15
- package/src/app/missions/page.tsx +135 -22
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/components/missions/mission-edit-sheet.tsx +319 -0
- package/src/components/missions/mission-template-gallery.tsx +113 -0
- package/src/components/missions/mission-template-install-dialog.tsx +283 -0
- package/src/lib/server/agents/agent-service.ts +10 -2
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +36 -0
- package/src/lib/server/agents/main-agent-loop.ts +111 -4
- package/src/lib/server/chat-execution/chat-turn-preparation.test.ts +253 -0
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +46 -26
- package/src/lib/server/chat-execution/message-classifier.ts +11 -7
- package/src/lib/server/chat-execution/post-stream-finalization.test.ts +85 -0
- package/src/lib/server/chat-execution/post-stream-finalization.ts +41 -16
- package/src/lib/server/chat-execution/response-completeness.test.ts +2 -1
- package/src/lib/server/chat-execution/response-completeness.ts +11 -3
- package/src/lib/server/chatrooms/chatroom-agent-signals.test.ts +54 -0
- package/src/lib/server/chatrooms/chatroom-agent-signals.ts +105 -9
- package/src/lib/server/chats/chat-session-service.ts +11 -0
- package/src/lib/server/connectors/email.test.ts +64 -0
- package/src/lib/server/connectors/email.ts +35 -6
- package/src/lib/server/connectors/response-media.ts +1 -0
- package/src/lib/server/daemon/daemon-runtime.ts +31 -19
- package/src/lib/server/memory/memory-db.test.ts +8 -0
- package/src/lib/server/memory/memory-db.ts +1 -1
- package/src/lib/server/missions/mission-service.ts +47 -1
- package/src/lib/server/missions/mission-templates.test.ts +208 -0
- package/src/lib/server/missions/mission-templates.ts +186 -0
- package/src/lib/server/runtime/session-run-manager/drain.ts +16 -0
- package/src/lib/server/storage-normalization.ts +6 -0
- package/src/lib/server/storage.ts +1 -1
- package/src/lib/server/tasks/task-validation.test.ts +30 -0
- package/src/lib/server/tasks/task-validation.ts +21 -2
- package/src/lib/server/working-state/normalization.ts +5 -1
- package/src/lib/validation/schemas.ts +40 -0
- package/src/types/mission.ts +27 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import type { MissionTemplate, MissionTemplateCategory } from '@/types'
|
|
5
|
+
|
|
6
|
+
const CATEGORY_LABELS: Record<MissionTemplateCategory, string> = {
|
|
7
|
+
research: 'Research',
|
|
8
|
+
communication: 'Communication',
|
|
9
|
+
monitoring: 'Monitoring',
|
|
10
|
+
productivity: 'Productivity',
|
|
11
|
+
support: 'Support',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
templates: MissionTemplate[]
|
|
16
|
+
onInstall: (template: MissionTemplate) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function MissionTemplateGallery({ templates, onInstall }: Props) {
|
|
20
|
+
const [category, setCategory] = useState<'all' | MissionTemplateCategory>('all')
|
|
21
|
+
|
|
22
|
+
const categories = useMemo(() => {
|
|
23
|
+
const seen = new Set<MissionTemplateCategory>()
|
|
24
|
+
for (const template of templates) seen.add(template.category)
|
|
25
|
+
return Array.from(seen)
|
|
26
|
+
}, [templates])
|
|
27
|
+
|
|
28
|
+
const filtered = useMemo(() => {
|
|
29
|
+
if (category === 'all') return templates
|
|
30
|
+
return templates.filter((t) => t.category === category)
|
|
31
|
+
}, [templates, category])
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex flex-col gap-4">
|
|
35
|
+
<div>
|
|
36
|
+
<h2 className="text-[15px] font-700 text-text">Start from a template</h2>
|
|
37
|
+
<p className="text-[12px] text-text-3 mt-1">
|
|
38
|
+
Pre-wired goals, budgets, and report schedules. Tweak anything before you install.
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div className="flex flex-wrap gap-1.5">
|
|
43
|
+
<FilterChip active={category === 'all'} onClick={() => setCategory('all')} label="All" />
|
|
44
|
+
{categories.map((c) => (
|
|
45
|
+
<FilterChip
|
|
46
|
+
key={c}
|
|
47
|
+
active={category === c}
|
|
48
|
+
onClick={() => setCategory(c)}
|
|
49
|
+
label={CATEGORY_LABELS[c]}
|
|
50
|
+
/>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
55
|
+
{filtered.map((template) => (
|
|
56
|
+
<TemplateCard key={template.id} template={template} onInstall={() => onInstall(template)} />
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function FilterChip({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
|
|
64
|
+
return (
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={onClick}
|
|
68
|
+
className={`text-[11px] font-600 px-2.5 py-1 rounded-full border transition-colors ${
|
|
69
|
+
active
|
|
70
|
+
? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-200'
|
|
71
|
+
: 'border-white/[0.08] bg-white/[0.02] text-text-3 hover:border-white/[0.16] hover:text-text'
|
|
72
|
+
}`}
|
|
73
|
+
>
|
|
74
|
+
{label}
|
|
75
|
+
</button>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function TemplateCard({ template, onInstall }: { template: MissionTemplate; onInstall: () => void }) {
|
|
80
|
+
return (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={onInstall}
|
|
84
|
+
className="text-left group flex flex-col gap-3 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4 transition-all hover:border-white/[0.16] hover:bg-white/[0.04]"
|
|
85
|
+
>
|
|
86
|
+
<div className="flex items-start gap-3">
|
|
87
|
+
<span className="text-[22px] leading-none" aria-hidden>{template.icon}</span>
|
|
88
|
+
<div className="flex-1 min-w-0">
|
|
89
|
+
<div className="text-[13px] font-700 text-text">{template.name}</div>
|
|
90
|
+
<div className="text-[10px] uppercase tracking-wide text-text-3/70 mt-0.5">
|
|
91
|
+
{CATEGORY_LABELS[template.category]}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<div className="text-[12px] text-text-3 leading-[1.55] line-clamp-3">{template.description}</div>
|
|
96
|
+
<div className="flex items-center justify-between mt-auto">
|
|
97
|
+
<div className="flex flex-wrap gap-1">
|
|
98
|
+
{template.tags.slice(0, 3).map((tag) => (
|
|
99
|
+
<span
|
|
100
|
+
key={tag}
|
|
101
|
+
className="text-[10px] text-text-3/70 px-1.5 py-0.5 rounded border border-white/[0.06] bg-white/[0.02]"
|
|
102
|
+
>
|
|
103
|
+
{tag}
|
|
104
|
+
</span>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
<span className="text-[11px] font-600 text-emerald-300 group-hover:text-emerald-200">
|
|
108
|
+
Install →
|
|
109
|
+
</span>
|
|
110
|
+
</div>
|
|
111
|
+
</button>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { HintTip } from '@/components/shared/hint-tip'
|
|
5
|
+
import { AdvancedSettingsSection } from '@/components/shared/advanced-settings-section'
|
|
6
|
+
import { inputClass } from '@/components/shared/form-styles'
|
|
7
|
+
import type { MissionTemplate, Session } from '@/types'
|
|
8
|
+
import { toast } from 'sonner'
|
|
9
|
+
|
|
10
|
+
export interface InstantiateInput {
|
|
11
|
+
rootSessionId: string
|
|
12
|
+
overrides: {
|
|
13
|
+
title: string
|
|
14
|
+
goal: string
|
|
15
|
+
successCriteria: string[]
|
|
16
|
+
budget: {
|
|
17
|
+
maxUsd: number | null
|
|
18
|
+
maxTokens: number | null
|
|
19
|
+
maxWallclockSec: number | null
|
|
20
|
+
maxTurns: number | null
|
|
21
|
+
}
|
|
22
|
+
reportSchedule: { intervalSec: number; format: 'markdown'; enabled: boolean } | null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
template: MissionTemplate | null
|
|
28
|
+
sessions: Session[]
|
|
29
|
+
onClose: () => void
|
|
30
|
+
onInstall: (template: MissionTemplate, input: InstantiateInput) => Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatDuration(sec: number | null | undefined): string {
|
|
34
|
+
if (sec == null) return '-'
|
|
35
|
+
if (sec < 60) return `${sec}s`
|
|
36
|
+
if (sec < 3600) return `${Math.round(sec / 60)}m`
|
|
37
|
+
if (sec < 86_400) return `${Math.round(sec / 3600)}h`
|
|
38
|
+
return `${Math.round(sec / 86_400)}d`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function numOrNull(s: string): number | null {
|
|
42
|
+
const n = Number.parseFloat(s)
|
|
43
|
+
return Number.isFinite(n) && n > 0 ? n : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function intOrNull(s: string): number | null {
|
|
47
|
+
const n = numOrNull(s)
|
|
48
|
+
return n == null ? null : Math.round(n)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function MissionTemplateInstallDialog({ template, sessions, onClose, onInstall }: Props) {
|
|
52
|
+
const [title, setTitle] = useState('')
|
|
53
|
+
const [goal, setGoal] = useState('')
|
|
54
|
+
const [criteriaText, setCriteriaText] = useState('')
|
|
55
|
+
const [rootSessionId, setRootSessionId] = useState('')
|
|
56
|
+
const [advancedOpen, setAdvancedOpen] = useState(false)
|
|
57
|
+
const [maxUsd, setMaxUsd] = useState('')
|
|
58
|
+
const [maxTokens, setMaxTokens] = useState('')
|
|
59
|
+
const [maxWallclockSec, setMaxWallclockSec] = useState('')
|
|
60
|
+
const [maxTurns, setMaxTurns] = useState('')
|
|
61
|
+
const [reportsEnabled, setReportsEnabled] = useState(true)
|
|
62
|
+
const [reportIntervalMin, setReportIntervalMin] = useState('')
|
|
63
|
+
const [busy, setBusy] = useState(false)
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!template) return
|
|
67
|
+
setTitle(template.defaults.title)
|
|
68
|
+
setGoal(template.defaults.goal)
|
|
69
|
+
setCriteriaText(template.defaults.successCriteria.join('\n'))
|
|
70
|
+
setMaxUsd(template.defaults.budget.maxUsd != null ? String(template.defaults.budget.maxUsd) : '')
|
|
71
|
+
setMaxTokens(template.defaults.budget.maxTokens != null ? String(template.defaults.budget.maxTokens) : '')
|
|
72
|
+
setMaxWallclockSec(template.defaults.budget.maxWallclockSec != null ? String(template.defaults.budget.maxWallclockSec) : '')
|
|
73
|
+
setMaxTurns(template.defaults.budget.maxTurns != null ? String(template.defaults.budget.maxTurns) : '')
|
|
74
|
+
setReportsEnabled(template.defaults.reportSchedule?.enabled ?? false)
|
|
75
|
+
setReportIntervalMin(
|
|
76
|
+
template.defaults.reportSchedule?.intervalSec != null
|
|
77
|
+
? String(Math.round(template.defaults.reportSchedule.intervalSec / 60))
|
|
78
|
+
: '60',
|
|
79
|
+
)
|
|
80
|
+
setAdvancedOpen(false)
|
|
81
|
+
}, [template])
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!rootSessionId && sessions.length > 0) setRootSessionId(sessions[0].id)
|
|
85
|
+
}, [sessions, rootSessionId])
|
|
86
|
+
|
|
87
|
+
const badges = useMemo(() => {
|
|
88
|
+
if (!template) return []
|
|
89
|
+
const out: string[] = []
|
|
90
|
+
if (template.defaults.budget.maxUsd != null) out.push(`$${template.defaults.budget.maxUsd} cap`)
|
|
91
|
+
if (template.defaults.budget.maxTurns != null) out.push(`${template.defaults.budget.maxTurns} turns`)
|
|
92
|
+
if (template.defaults.budget.maxWallclockSec != null) out.push(formatDuration(template.defaults.budget.maxWallclockSec))
|
|
93
|
+
if (template.defaults.reportSchedule) out.push(`Reports every ${formatDuration(template.defaults.reportSchedule.intervalSec)}`)
|
|
94
|
+
return out
|
|
95
|
+
}, [template])
|
|
96
|
+
|
|
97
|
+
if (!template) return null
|
|
98
|
+
|
|
99
|
+
const submit = async () => {
|
|
100
|
+
if (!rootSessionId) {
|
|
101
|
+
toast.error('Pick a session to drive this mission')
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (!title.trim() || !goal.trim()) {
|
|
105
|
+
toast.error('Title and goal are required')
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
setBusy(true)
|
|
109
|
+
try {
|
|
110
|
+
const successCriteria = criteriaText.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
111
|
+
const intervalMin = numOrNull(reportIntervalMin) ?? 60
|
|
112
|
+
await onInstall(template, {
|
|
113
|
+
rootSessionId,
|
|
114
|
+
overrides: {
|
|
115
|
+
title: title.trim(),
|
|
116
|
+
goal: goal.trim(),
|
|
117
|
+
successCriteria,
|
|
118
|
+
budget: {
|
|
119
|
+
maxUsd: numOrNull(maxUsd),
|
|
120
|
+
maxTokens: intOrNull(maxTokens),
|
|
121
|
+
maxWallclockSec: intOrNull(maxWallclockSec),
|
|
122
|
+
maxTurns: intOrNull(maxTurns),
|
|
123
|
+
},
|
|
124
|
+
reportSchedule: reportsEnabled
|
|
125
|
+
? { intervalSec: Math.round(intervalMin * 60), format: 'markdown', enabled: true }
|
|
126
|
+
: null,
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
onClose()
|
|
130
|
+
} catch (error) {
|
|
131
|
+
toast.error(`Install failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
132
|
+
} finally {
|
|
133
|
+
setBusy(false)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
139
|
+
<div
|
|
140
|
+
className="w-full max-w-xl rounded-[14px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-5 max-h-[92vh] overflow-y-auto"
|
|
141
|
+
onClick={(e) => e.stopPropagation()}
|
|
142
|
+
>
|
|
143
|
+
<div className="flex items-start gap-3 mb-4">
|
|
144
|
+
<span className="text-[28px] leading-none" aria-hidden>{template.icon}</span>
|
|
145
|
+
<div className="min-w-0 flex-1">
|
|
146
|
+
<div className="text-[15px] font-700 text-text">{template.name}</div>
|
|
147
|
+
<div className="text-[12px] text-text-3 leading-[1.5] mt-1">{template.description}</div>
|
|
148
|
+
{badges.length > 0 && (
|
|
149
|
+
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
|
150
|
+
{badges.map((badge) => (
|
|
151
|
+
<span
|
|
152
|
+
key={badge}
|
|
153
|
+
className="text-[10px] font-600 px-1.5 py-0.5 rounded border border-white/[0.08] bg-white/[0.02] text-text-3"
|
|
154
|
+
>
|
|
155
|
+
{badge}
|
|
156
|
+
</span>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{template.setupNote && (
|
|
164
|
+
<div className="mb-4 text-[11px] text-amber-200/90 bg-amber-500/10 border border-amber-500/20 rounded-[10px] px-3 py-2 leading-[1.5]">
|
|
165
|
+
<span className="font-700">Setup: </span>
|
|
166
|
+
{template.setupNote}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
<div className="flex flex-col gap-3">
|
|
171
|
+
<label className="flex flex-col gap-1">
|
|
172
|
+
<span className="text-[11px] text-text-3">Title</span>
|
|
173
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} className={inputClass} />
|
|
174
|
+
</label>
|
|
175
|
+
|
|
176
|
+
<label className="flex flex-col gap-1">
|
|
177
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
178
|
+
Goal <HintTip text="The natural-language objective your team will work on." />
|
|
179
|
+
</span>
|
|
180
|
+
<textarea
|
|
181
|
+
value={goal}
|
|
182
|
+
onChange={(e) => setGoal(e.target.value)}
|
|
183
|
+
rows={4}
|
|
184
|
+
className={`${inputClass} resize-none`}
|
|
185
|
+
/>
|
|
186
|
+
</label>
|
|
187
|
+
|
|
188
|
+
<label className="flex flex-col gap-1">
|
|
189
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
190
|
+
Root session <HintTip text="The session whose heartbeat drives this mission." />
|
|
191
|
+
</span>
|
|
192
|
+
<select value={rootSessionId} onChange={(e) => setRootSessionId(e.target.value)} className={inputClass}>
|
|
193
|
+
{sessions.length === 0 && <option value="">No sessions available. Create a chat first.</option>}
|
|
194
|
+
{sessions.map((s) => (
|
|
195
|
+
<option key={s.id} value={s.id}>
|
|
196
|
+
{s.name || s.id}
|
|
197
|
+
</option>
|
|
198
|
+
))}
|
|
199
|
+
</select>
|
|
200
|
+
</label>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div className="mt-4">
|
|
204
|
+
<AdvancedSettingsSection
|
|
205
|
+
open={advancedOpen}
|
|
206
|
+
onToggle={() => setAdvancedOpen((o) => !o)}
|
|
207
|
+
summary="Budgets, criteria, reports"
|
|
208
|
+
>
|
|
209
|
+
<div className="flex flex-col gap-4">
|
|
210
|
+
<label className="flex flex-col gap-1">
|
|
211
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
212
|
+
Success criteria <HintTip text="One per line. Used in reports and final verification." />
|
|
213
|
+
</span>
|
|
214
|
+
<textarea
|
|
215
|
+
value={criteriaText}
|
|
216
|
+
onChange={(e) => setCriteriaText(e.target.value)}
|
|
217
|
+
rows={4}
|
|
218
|
+
className={`${inputClass} resize-none`}
|
|
219
|
+
/>
|
|
220
|
+
</label>
|
|
221
|
+
|
|
222
|
+
<div className="grid grid-cols-2 gap-3">
|
|
223
|
+
<label className="flex flex-col gap-1">
|
|
224
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
225
|
+
Max USD <HintTip text="Hard spend cap. Leave blank for no limit." />
|
|
226
|
+
</span>
|
|
227
|
+
<input value={maxUsd} onChange={(e) => setMaxUsd(e.target.value)} className={inputClass} inputMode="decimal" />
|
|
228
|
+
</label>
|
|
229
|
+
<label className="flex flex-col gap-1">
|
|
230
|
+
<span className="text-[11px] text-text-3">Max tokens</span>
|
|
231
|
+
<input value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} className={inputClass} inputMode="numeric" />
|
|
232
|
+
</label>
|
|
233
|
+
<label className="flex flex-col gap-1">
|
|
234
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
235
|
+
Max wallclock (sec) <HintTip text="Scheduler aborts after this many seconds of elapsed wallclock." />
|
|
236
|
+
</span>
|
|
237
|
+
<input value={maxWallclockSec} onChange={(e) => setMaxWallclockSec(e.target.value)} className={inputClass} inputMode="numeric" />
|
|
238
|
+
</label>
|
|
239
|
+
<label className="flex flex-col gap-1">
|
|
240
|
+
<span className="text-[11px] text-text-3">Max turns</span>
|
|
241
|
+
<input value={maxTurns} onChange={(e) => setMaxTurns(e.target.value)} className={inputClass} inputMode="numeric" />
|
|
242
|
+
</label>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
|
|
246
|
+
<div className="text-[11px] font-600 text-text-3 uppercase tracking-wide mb-1.5">Periodic reports</div>
|
|
247
|
+
<label className="flex items-center gap-2 flex-wrap">
|
|
248
|
+
<input type="checkbox" checked={reportsEnabled} onChange={(e) => setReportsEnabled(e.target.checked)} />
|
|
249
|
+
<span className="text-[11px] text-text-3">Send a markdown progress report every</span>
|
|
250
|
+
<input
|
|
251
|
+
disabled={!reportsEnabled}
|
|
252
|
+
value={reportIntervalMin}
|
|
253
|
+
onChange={(e) => setReportIntervalMin(e.target.value)}
|
|
254
|
+
className={`${inputClass} w-16`}
|
|
255
|
+
inputMode="numeric"
|
|
256
|
+
/>
|
|
257
|
+
<span className="text-[11px] text-text-3">minutes</span>
|
|
258
|
+
</label>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</AdvancedSettingsSection>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<div className="mt-5 flex items-center justify-end gap-2">
|
|
265
|
+
<button
|
|
266
|
+
onClick={onClose}
|
|
267
|
+
className="text-[12px] px-3 py-1.5 rounded border border-white/[0.08] hover:bg-white/[0.04]"
|
|
268
|
+
disabled={busy}
|
|
269
|
+
>
|
|
270
|
+
Cancel
|
|
271
|
+
</button>
|
|
272
|
+
<button
|
|
273
|
+
onClick={submit}
|
|
274
|
+
disabled={busy}
|
|
275
|
+
className="text-[12px] font-600 px-3 py-1.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 hover:bg-emerald-500/25 disabled:opacity-40"
|
|
276
|
+
>
|
|
277
|
+
{busy ? 'Installing…' : 'Install mission'}
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
@@ -207,11 +207,19 @@ export function createAgent(input: {
|
|
|
207
207
|
export function updateAgent(agentId: string, body: Record<string, unknown>): Agent | null {
|
|
208
208
|
const updated = patchAgent(agentId, (current) => {
|
|
209
209
|
if (!current) return null
|
|
210
|
+
if (body.projectId === undefined && Array.isArray(body.projectIds) && body.projectIds.length > 0) {
|
|
211
|
+
const first = body.projectIds[0]
|
|
212
|
+
if (typeof first === 'string' && first.trim()) {
|
|
213
|
+
body.projectId = first.trim()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
210
216
|
const agent = { ...current, ...body, updatedAt: Date.now() }
|
|
211
217
|
if (body.tools !== undefined || body.extensions !== undefined) {
|
|
218
|
+
// Fall back to `current` (pre-spread) so a non-array body value does not
|
|
219
|
+
// clobber the existing list via `{...current, ...body}` above.
|
|
212
220
|
const nextSelection = normalizeCapabilitySelection({
|
|
213
|
-
tools: Array.isArray(body.tools) ? body.tools :
|
|
214
|
-
extensions: Array.isArray(body.extensions) ? body.extensions :
|
|
221
|
+
tools: Array.isArray(body.tools) ? body.tools : current.tools,
|
|
222
|
+
extensions: Array.isArray(body.extensions) ? body.extensions : current.extensions,
|
|
215
223
|
})
|
|
216
224
|
agent.tools = nextSelection.tools
|
|
217
225
|
agent.extensions = nextSelection.extensions
|
|
@@ -668,6 +668,42 @@ describe('main-agent-loop advanced', () => {
|
|
|
668
668
|
assert.equal(result, '')
|
|
669
669
|
})
|
|
670
670
|
|
|
671
|
+
it('stripMainLoopMetaForPersistence strips trailing factsUpsert / isIncomplete blobs glued to assistant text', () => {
|
|
672
|
+
const input = 'The 8th Fibonacci number is **13**.{"isIncomplete": false, "confidence": 1.0}{"factsUpsert":[{"statement":"The 8th Fibonacci number is 13.","source":"assistant","status":"active","evidenceIds":[]}]}'
|
|
673
|
+
const result = stripMainLoopMetaForPersistence(input)
|
|
674
|
+
assert.ok(!result.includes('factsUpsert'), 'factsUpsert payload removed')
|
|
675
|
+
assert.ok(!result.includes('isIncomplete'), 'isIncomplete payload removed')
|
|
676
|
+
assert.ok(result.includes('The 8th Fibonacci number is **13**.'), 'visible answer preserved')
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it('stripMainLoopMetaForPersistence preserves benign user-facing JSON snippets', () => {
|
|
680
|
+
const benign = [
|
|
681
|
+
'Here is the example config you asked for:',
|
|
682
|
+
'{"port":3000,"host":"localhost","tls":{"enabled":true}}',
|
|
683
|
+
'And a smaller variant:',
|
|
684
|
+
'{"status":"ok","goal":"render"}',
|
|
685
|
+
'{"confidence":0.9,"score":0.7}',
|
|
686
|
+
].join('\n')
|
|
687
|
+
const result = stripMainLoopMetaForPersistence(benign)
|
|
688
|
+
assert.ok(result.includes('"port":3000'), 'config block preserved')
|
|
689
|
+
assert.ok(result.includes('"status":"ok"'), 'status snippet preserved')
|
|
690
|
+
assert.ok(result.includes('"confidence":0.9,"score":0.7'), 'confidence-only blob preserved')
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it('stripMainLoopMetaForPersistence strips multi-line working-state JSON', () => {
|
|
694
|
+
const input = [
|
|
695
|
+
'Here is the answer.',
|
|
696
|
+
'{',
|
|
697
|
+
' "factsUpsert": [',
|
|
698
|
+
' { "statement": "x", "source": "assistant", "status": "active", "evidenceIds": [] }',
|
|
699
|
+
' ]',
|
|
700
|
+
'}',
|
|
701
|
+
].join('\n')
|
|
702
|
+
const result = stripMainLoopMetaForPersistence(input)
|
|
703
|
+
assert.ok(!result.includes('factsUpsert'), 'multi-line blob removed')
|
|
704
|
+
assert.ok(result.includes('Here is the answer.'), 'visible answer preserved')
|
|
705
|
+
})
|
|
706
|
+
|
|
671
707
|
// ─────────────────────────────────────────────────────────────────────
|
|
672
708
|
// 9. Status transitions (direct import via subprocess for state access)
|
|
673
709
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -1113,22 +1113,129 @@ export function buildMainLoopHeartbeatPrompt(session: unknown, fallbackPrompt: s
|
|
|
1113
1113
|
].filter(Boolean).join('\n')
|
|
1114
1114
|
}
|
|
1115
1115
|
|
|
1116
|
+
import { z } from 'zod'
|
|
1117
|
+
import { WorkingStatePatchSchema } from '@/lib/server/working-state/normalization'
|
|
1118
|
+
import { MessageClassificationSchema } from '@/lib/server/chat-execution/message-classifier'
|
|
1119
|
+
import { ResponseCompletenessSchema } from '@/lib/server/chat-execution/response-completeness'
|
|
1120
|
+
|
|
1121
|
+
// Side-channel classifier outputs that occasionally bleed into the visible
|
|
1122
|
+
// assistant text from cloud models. Each rule pairs a zod schema with at
|
|
1123
|
+
// least one distinctive field so unrelated JSON the user actually wants to
|
|
1124
|
+
// see (config blobs, API examples, snippets like {"status":"ok"}) stays in
|
|
1125
|
+
// the message instead of being silently scrubbed.
|
|
1126
|
+
const QualityScoreSchema = z.object({
|
|
1127
|
+
quality_score: z.number(),
|
|
1128
|
+
quality_reasoning: z.string(),
|
|
1129
|
+
}).passthrough()
|
|
1130
|
+
|
|
1131
|
+
interface InternalPayloadRule {
|
|
1132
|
+
schema: z.ZodType<unknown>
|
|
1133
|
+
distinctiveKeys: string[]
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const INTERNAL_PAYLOAD_RULES: InternalPayloadRule[] = [
|
|
1137
|
+
{
|
|
1138
|
+
schema: WorkingStatePatchSchema,
|
|
1139
|
+
distinctiveKeys: [
|
|
1140
|
+
'factsUpsert',
|
|
1141
|
+
'artifactsUpsert',
|
|
1142
|
+
'planSteps',
|
|
1143
|
+
'decisionsAppend',
|
|
1144
|
+
'blockersUpsert',
|
|
1145
|
+
'questionsUpsert',
|
|
1146
|
+
'hypothesesUpsert',
|
|
1147
|
+
'supersedeIds',
|
|
1148
|
+
],
|
|
1149
|
+
},
|
|
1150
|
+
{
|
|
1151
|
+
schema: MessageClassificationSchema,
|
|
1152
|
+
distinctiveKeys: ['taskIntent', 'isLightweightDirectChat', 'isDeliverableTask'],
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
schema: ResponseCompletenessSchema,
|
|
1156
|
+
distinctiveKeys: ['isIncomplete'],
|
|
1157
|
+
},
|
|
1158
|
+
{
|
|
1159
|
+
schema: QualityScoreSchema,
|
|
1160
|
+
distinctiveKeys: ['quality_score'],
|
|
1161
|
+
},
|
|
1162
|
+
]
|
|
1163
|
+
|
|
1164
|
+
function objectIsInternalMetadata(obj: Record<string, unknown>): boolean {
|
|
1165
|
+
for (const { schema, distinctiveKeys } of INTERNAL_PAYLOAD_RULES) {
|
|
1166
|
+
if (!distinctiveKeys.some((key) => key in obj)) continue
|
|
1167
|
+
if (schema.safeParse(obj).success) return true
|
|
1168
|
+
}
|
|
1169
|
+
return false
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1116
1172
|
function isInternalMetadataJson(line: string): boolean {
|
|
1117
1173
|
const trimmed = line.trim()
|
|
1118
1174
|
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return false
|
|
1119
1175
|
try {
|
|
1120
1176
|
const obj = JSON.parse(trimmed)
|
|
1121
1177
|
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return false
|
|
1122
|
-
|
|
1123
|
-
if ('quality_score' in obj && 'quality_reasoning' in obj) return true
|
|
1124
|
-
return false
|
|
1178
|
+
return objectIsInternalMetadata(obj as Record<string, unknown>)
|
|
1125
1179
|
} catch {
|
|
1126
1180
|
return false
|
|
1127
1181
|
}
|
|
1128
1182
|
}
|
|
1129
1183
|
|
|
1184
|
+
function findBalancedJsonObjectEnd(text: string, start: number): number {
|
|
1185
|
+
if (text.charAt(start) !== '{') return -1
|
|
1186
|
+
let depth = 0
|
|
1187
|
+
let inString = false
|
|
1188
|
+
let escaped = false
|
|
1189
|
+
for (let i = start; i < text.length; i += 1) {
|
|
1190
|
+
const c = text.charAt(i)
|
|
1191
|
+
if (inString) {
|
|
1192
|
+
if (escaped) escaped = false
|
|
1193
|
+
else if (c === '\\') escaped = true
|
|
1194
|
+
else if (c === '"') inString = false
|
|
1195
|
+
continue
|
|
1196
|
+
}
|
|
1197
|
+
if (c === '"') {
|
|
1198
|
+
inString = true
|
|
1199
|
+
continue
|
|
1200
|
+
}
|
|
1201
|
+
if (c === '{') depth += 1
|
|
1202
|
+
else if (c === '}') {
|
|
1203
|
+
depth -= 1
|
|
1204
|
+
if (depth === 0) return i + 1
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return -1
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function stripInternalJsonBlobs(text: string): string {
|
|
1211
|
+
let out = text || ''
|
|
1212
|
+
for (let guard = 0; guard < 16; guard += 1) {
|
|
1213
|
+
let removed = false
|
|
1214
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
1215
|
+
if (out.charAt(i) !== '{') continue
|
|
1216
|
+
const end = findBalancedJsonObjectEnd(out, i)
|
|
1217
|
+
if (end <= i) continue
|
|
1218
|
+
const candidate = out.slice(i, end)
|
|
1219
|
+
let parsed: unknown
|
|
1220
|
+
try {
|
|
1221
|
+
parsed = JSON.parse(candidate)
|
|
1222
|
+
} catch {
|
|
1223
|
+
continue
|
|
1224
|
+
}
|
|
1225
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
|
|
1226
|
+
if (!objectIsInternalMetadata(parsed as Record<string, unknown>)) continue
|
|
1227
|
+
out = (out.slice(0, i).replace(/\s+$/, '') + ' ' + out.slice(end).replace(/^\s+/, '')).trim()
|
|
1228
|
+
removed = true
|
|
1229
|
+
break
|
|
1230
|
+
}
|
|
1231
|
+
if (!removed) break
|
|
1232
|
+
}
|
|
1233
|
+
return out
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1130
1236
|
export function stripMainLoopMetaForPersistence(text: string): string {
|
|
1131
|
-
|
|
1237
|
+
const withoutBlobs = stripInternalJsonBlobs(text || '')
|
|
1238
|
+
return withoutBlobs
|
|
1132
1239
|
.split('\n')
|
|
1133
1240
|
.filter((line) => !LEGACY_META_LINE_RE.test(line) && !isInternalMetadataJson(line))
|
|
1134
1241
|
.join('\n')
|