@swarmclawai/swarmclaw 1.5.53 → 1.5.54
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 +7 -3
- package/package.json +1 -1
- 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/missions/page.tsx +97 -14
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/components/missions/mission-template-gallery.tsx +113 -0
- package/src/components/missions/mission-template-install-dialog.tsx +278 -0
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +25 -14
- package/src/lib/server/chats/chat-session-service.ts +11 -0
- 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/storage-normalization.ts +6 -0
- package/src/types/mission.ts +27 -0
package/README.md
CHANGED
|
@@ -399,13 +399,17 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
-
### v1.5.
|
|
402
|
+
### v1.5.54 Highlights
|
|
403
403
|
|
|
404
404
|
- **Mission templates library**: the `/missions` page now opens with a curated gallery of starter missions. Each template pre-wires a goal, success criteria, USD / token / turn / wallclock budgets, and a report cadence, so non-technical users can install a working autonomous run in one click. Initial lineup: Daily News Digest, Inbox Triage, Competitor Watch, Weekly Research Report, Social Listener, and Customer Support Triage. Setup notes flag any connector or permission prerequisites before installation. Power-user overrides (budget caps, success criteria, report cadence) live behind a collapsed **Advanced Settings** panel so the default install flow stays one click.
|
|
405
405
|
- **New API routes `GET /api/missions/templates` and `POST /api/missions/templates/:id/instantiate`** with matching CLI commands `swarmclaw missions templates` and `swarmclaw missions instantiate`. Installed missions persist a `templateId` so the origin is traceable for future template-update flows; legacy missions normalize to `templateId: null` on load, no data migration required.
|
|
406
|
-
- **Fix:
|
|
406
|
+
- **Fix: user-selected provider and model now survive the chat execution pipeline** ([#51](https://github.com/swarmclawai/swarmclaw/pull/51), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). Switching provider or model via the inspector panel mid-session was being reverted on every turn because the agent's configured route was unconditionally reapplied in three places. `syncSessionFromAgent` now only syncs credentials / endpoint / fallbacks when the session's provider still matches the route provider, `prepareChatTurn` preserves the user's chosen model after applying the route, and `updateChatSession` auto-resolves a stored credential for the new provider (and clears the stale `apiEndpoint`) when provider changes without an explicit `credentialId`. Restores reliable switching between Copilot CLI, Codex CLI, Groq, and OpenAI-compatible providers.
|
|
407
|
+
|
|
408
|
+
> **Note:** v1.5.53 release notes described the mission templates library, but the feature commit landed after the v1.5.53 tag was cut. v1.5.54 is the release that actually ships it.
|
|
409
|
+
|
|
410
|
+
### v1.5.53 Highlights
|
|
407
411
|
|
|
408
|
-
|
|
412
|
+
- **Fix: switching a session's model now sticks in the UI** ([#50](https://github.com/swarmclawai/swarmclaw/pull/50), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). The **Switch Model** panel in the agent inspector was reading from `agent.provider` / `agent.model` (the agent's defaults) instead of `session.provider` / `session.model`, so after saving a model switch the collapsed pill still showed the agent default, the combobox reset to the default when reopened, and `selectedProvider` reverted on every save. `ModelSwitcherInline` now uses `session.provider || agent.provider` and `session.model || agent.model` as the source of truth, and its `useEffect` syncs to `session.provider` changes so a successful save updates the panel immediately.
|
|
409
413
|
|
|
410
414
|
### v1.5.52 Highlights
|
|
411
415
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.54",
|
|
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",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
4
|
+
import { formatZodError } from '@/lib/validation/schemas'
|
|
5
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
6
|
+
import { createMissionFromTemplate } from '@/lib/server/missions/mission-service'
|
|
7
|
+
import { patchSession } from '@/lib/server/sessions/session-repository'
|
|
8
|
+
|
|
9
|
+
export const dynamic = 'force-dynamic'
|
|
10
|
+
|
|
11
|
+
const BudgetOverrideSchema = z.object({
|
|
12
|
+
maxUsd: z.number().positive().nullable().optional(),
|
|
13
|
+
maxTokens: z.number().positive().int().nullable().optional(),
|
|
14
|
+
maxToolCalls: z.number().positive().int().nullable().optional(),
|
|
15
|
+
maxWallclockSec: z.number().positive().int().nullable().optional(),
|
|
16
|
+
maxTurns: z.number().positive().int().nullable().optional(),
|
|
17
|
+
warnAtFractions: z.array(z.number().positive().lt(1)).max(10).optional(),
|
|
18
|
+
}).partial()
|
|
19
|
+
|
|
20
|
+
const ReportScheduleSchema = z.object({
|
|
21
|
+
intervalSec: z.number().int().min(30),
|
|
22
|
+
format: z.enum(['markdown', 'slack', 'discord', 'email', 'audio']),
|
|
23
|
+
enabled: z.boolean().default(true),
|
|
24
|
+
lastReportAt: z.number().nullable().optional(),
|
|
25
|
+
}).strict()
|
|
26
|
+
|
|
27
|
+
const InstantiateSchema = z.object({
|
|
28
|
+
rootSessionId: z.string().min(1, 'rootSessionId is required'),
|
|
29
|
+
overrides: z.object({
|
|
30
|
+
title: z.string().min(1).max(200).optional(),
|
|
31
|
+
goal: z.string().min(1).max(4000).optional(),
|
|
32
|
+
successCriteria: z.array(z.string().min(1)).max(32).optional(),
|
|
33
|
+
budget: BudgetOverrideSchema.optional(),
|
|
34
|
+
reportSchedule: ReportScheduleSchema.nullable().optional(),
|
|
35
|
+
agentIds: z.array(z.string().min(1)).max(32).optional(),
|
|
36
|
+
reportConnectorIds: z.array(z.string().min(1)).max(8).optional(),
|
|
37
|
+
}).optional(),
|
|
38
|
+
}).strict()
|
|
39
|
+
|
|
40
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
41
|
+
const { id } = await params
|
|
42
|
+
const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
|
|
43
|
+
if (error) return error
|
|
44
|
+
const parsed = InstantiateSchema.safeParse(body)
|
|
45
|
+
if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
|
|
46
|
+
|
|
47
|
+
const result = createMissionFromTemplate({
|
|
48
|
+
templateId: id,
|
|
49
|
+
rootSessionId: parsed.data.rootSessionId,
|
|
50
|
+
overrides: parsed.data.overrides,
|
|
51
|
+
})
|
|
52
|
+
if (!result) return notFound()
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
patchSession(result.mission.rootSessionId, (current) => {
|
|
56
|
+
if (!current) return null
|
|
57
|
+
return { ...current, missionId: result.mission.id }
|
|
58
|
+
})
|
|
59
|
+
} catch {
|
|
60
|
+
// Session may not exist yet; budget hook falls back to service map.
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return NextResponse.json({ mission: result.mission, template: result.template })
|
|
64
|
+
}
|
|
@@ -5,7 +5,12 @@ import { api } from '@/lib/app/api-client'
|
|
|
5
5
|
import { MainContent } from '@/components/layout/main-content'
|
|
6
6
|
import { HintTip } from '@/components/shared/hint-tip'
|
|
7
7
|
import { inputClass } from '@/components/shared/form-styles'
|
|
8
|
-
import
|
|
8
|
+
import { MissionTemplateGallery } from '@/components/missions/mission-template-gallery'
|
|
9
|
+
import {
|
|
10
|
+
MissionTemplateInstallDialog,
|
|
11
|
+
type InstantiateInput,
|
|
12
|
+
} from '@/components/missions/mission-template-install-dialog'
|
|
13
|
+
import type { Mission, MissionReport, MissionEvent, MissionTemplate, Session } from '@/types'
|
|
9
14
|
import { toast } from 'sonner'
|
|
10
15
|
|
|
11
16
|
const POLL_MS = 4_000
|
|
@@ -489,6 +494,9 @@ export default function MissionsPage() {
|
|
|
489
494
|
const [createOpen, setCreateOpen] = useState(false)
|
|
490
495
|
const [busy, setBusy] = useState(false)
|
|
491
496
|
const [loaded, setLoaded] = useState(false)
|
|
497
|
+
const [templates, setTemplates] = useState<MissionTemplate[]>([])
|
|
498
|
+
const [galleryOpen, setGalleryOpen] = useState(false)
|
|
499
|
+
const [installTemplate, setInstallTemplate] = useState<MissionTemplate | null>(null)
|
|
492
500
|
|
|
493
501
|
const selected = useMemo(() => missions.find((m) => m.id === selectedId) ?? null, [missions, selectedId])
|
|
494
502
|
|
|
@@ -534,7 +542,15 @@ export default function MissionsPage() {
|
|
|
534
542
|
api<Session[]>('GET', '/chats').then((s) => {
|
|
535
543
|
setSessions(Array.isArray(s) ? s : Object.values(s))
|
|
536
544
|
}).catch(() => setSessions([]))
|
|
537
|
-
}, [createOpen])
|
|
545
|
+
}, [createOpen, galleryOpen, installTemplate])
|
|
546
|
+
|
|
547
|
+
useEffect(() => {
|
|
548
|
+
let cancelled = false
|
|
549
|
+
api<MissionTemplate[]>('GET', '/missions/templates')
|
|
550
|
+
.then((list) => { if (!cancelled) setTemplates(Array.isArray(list) ? list : []) })
|
|
551
|
+
.catch(() => { if (!cancelled) setTemplates([]) })
|
|
552
|
+
return () => { cancelled = true }
|
|
553
|
+
}, [])
|
|
538
554
|
|
|
539
555
|
const handleAction = useCallback(async (action: string, reason?: string) => {
|
|
540
556
|
if (!selectedId) return
|
|
@@ -571,28 +587,60 @@ export default function MissionsPage() {
|
|
|
571
587
|
toast.success(`Mission "${created.title}" created`)
|
|
572
588
|
}, [refreshList])
|
|
573
589
|
|
|
590
|
+
const handleInstallTemplate = useCallback(async (template: MissionTemplate, input: InstantiateInput) => {
|
|
591
|
+
const result = await api<{ mission: Mission }>(
|
|
592
|
+
'POST',
|
|
593
|
+
`/missions/templates/${encodeURIComponent(template.id)}/instantiate`,
|
|
594
|
+
input,
|
|
595
|
+
)
|
|
596
|
+
await refreshList()
|
|
597
|
+
setSelectedId(result.mission.id)
|
|
598
|
+
setGalleryOpen(false)
|
|
599
|
+
toast.success(`Mission "${result.mission.title}" installed`)
|
|
600
|
+
}, [refreshList])
|
|
601
|
+
|
|
574
602
|
return (
|
|
575
603
|
<MainContent>
|
|
576
604
|
<div className="flex-1 flex min-h-0">
|
|
577
605
|
<div className="w-[340px] shrink-0 border-r border-white/[0.06] flex flex-col min-h-0">
|
|
578
|
-
<div className="p-3 border-b border-white/[0.06]
|
|
579
|
-
<div>
|
|
580
|
-
<div
|
|
581
|
-
|
|
606
|
+
<div className="p-3 border-b border-white/[0.06]">
|
|
607
|
+
<div className="flex items-center justify-between mb-2">
|
|
608
|
+
<div>
|
|
609
|
+
<div className="text-[13px] font-600">Missions</div>
|
|
610
|
+
<div className="text-[10px] text-text-3">Autonomous goal-driven runs</div>
|
|
611
|
+
</div>
|
|
612
|
+
<button
|
|
613
|
+
onClick={() => setCreateOpen(true)}
|
|
614
|
+
className="text-[11px] font-600 px-2.5 py-1 rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15"
|
|
615
|
+
>
|
|
616
|
+
+ New
|
|
617
|
+
</button>
|
|
582
618
|
</div>
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
619
|
+
{templates.length > 0 && (
|
|
620
|
+
<button
|
|
621
|
+
onClick={() => setGalleryOpen(true)}
|
|
622
|
+
className="w-full text-left text-[11px] font-600 px-2.5 py-1.5 rounded border border-white/[0.08] bg-white/[0.02] text-text-3 hover:border-white/[0.16] hover:text-text"
|
|
623
|
+
>
|
|
624
|
+
Browse {templates.length} starter templates →
|
|
625
|
+
</button>
|
|
626
|
+
)}
|
|
589
627
|
</div>
|
|
590
628
|
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1.5">
|
|
591
629
|
{!loaded ? (
|
|
592
630
|
<div className="text-[11px] text-text-3 p-3">Loading...</div>
|
|
593
631
|
) : missions.length === 0 ? (
|
|
594
|
-
<div className="
|
|
595
|
-
|
|
632
|
+
<div className="flex flex-col gap-2 p-3">
|
|
633
|
+
<div className="text-[11px] text-text-3">
|
|
634
|
+
No missions yet. Start from a template or create one from scratch.
|
|
635
|
+
</div>
|
|
636
|
+
{templates.length > 0 && (
|
|
637
|
+
<button
|
|
638
|
+
onClick={() => setGalleryOpen(true)}
|
|
639
|
+
className="text-[11px] font-600 px-2.5 py-1 rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15 self-start"
|
|
640
|
+
>
|
|
641
|
+
Open template gallery
|
|
642
|
+
</button>
|
|
643
|
+
)}
|
|
596
644
|
</div>
|
|
597
645
|
) : (
|
|
598
646
|
missions.map((m) => (
|
|
@@ -616,6 +664,13 @@ export default function MissionsPage() {
|
|
|
616
664
|
onAction={handleAction}
|
|
617
665
|
onForceReport={handleForceReport}
|
|
618
666
|
/>
|
|
667
|
+
) : loaded && missions.length === 0 && templates.length > 0 ? (
|
|
668
|
+
<div className="p-6">
|
|
669
|
+
<MissionTemplateGallery
|
|
670
|
+
templates={templates}
|
|
671
|
+
onInstall={(t) => setInstallTemplate(t)}
|
|
672
|
+
/>
|
|
673
|
+
</div>
|
|
619
674
|
) : (
|
|
620
675
|
<div className="flex items-center justify-center h-full text-text-3 text-[12px]">
|
|
621
676
|
{loaded && missions.length === 0 ? 'Create a mission to get started.' : 'Select a mission'}
|
|
@@ -630,6 +685,34 @@ export default function MissionsPage() {
|
|
|
630
685
|
onClose={() => setCreateOpen(false)}
|
|
631
686
|
onCreate={handleCreate}
|
|
632
687
|
/>
|
|
688
|
+
|
|
689
|
+
{galleryOpen && (
|
|
690
|
+
<div
|
|
691
|
+
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
|
692
|
+
onClick={() => setGalleryOpen(false)}
|
|
693
|
+
>
|
|
694
|
+
<div
|
|
695
|
+
className="w-full max-w-4xl max-h-[90vh] overflow-y-auto rounded-[14px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-6"
|
|
696
|
+
onClick={(e) => e.stopPropagation()}
|
|
697
|
+
>
|
|
698
|
+
<div className="flex items-center justify-between mb-4">
|
|
699
|
+
<div className="text-[15px] font-700 text-text">Mission templates</div>
|
|
700
|
+
<button onClick={() => setGalleryOpen(false)} className="text-text-3 text-[12px] hover:text-text">Close</button>
|
|
701
|
+
</div>
|
|
702
|
+
<MissionTemplateGallery
|
|
703
|
+
templates={templates}
|
|
704
|
+
onInstall={(t) => setInstallTemplate(t)}
|
|
705
|
+
/>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
)}
|
|
709
|
+
|
|
710
|
+
<MissionTemplateInstallDialog
|
|
711
|
+
template={installTemplate}
|
|
712
|
+
sessions={sessions}
|
|
713
|
+
onClose={() => setInstallTemplate(null)}
|
|
714
|
+
onInstall={handleInstallTemplate}
|
|
715
|
+
/>
|
|
633
716
|
</MainContent>
|
|
634
717
|
)
|
|
635
718
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -817,6 +817,8 @@ const COMMAND_GROUPS = [
|
|
|
817
817
|
cmd('reports', 'GET', '/missions/:id/reports', 'List mission reports'),
|
|
818
818
|
cmd('report-now', 'POST', '/missions/:id/reports', 'Force-generate a mission report now'),
|
|
819
819
|
cmd('events', 'GET', '/missions/:id/events', 'List mission events (use --query sinceAt=..., --query untilAt=...)'),
|
|
820
|
+
cmd('templates', 'GET', '/missions/templates', 'List built-in mission templates'),
|
|
821
|
+
cmd('instantiate', 'POST', '/missions/templates/:id/instantiate', 'Create a mission from a template', { expectsJsonBody: true }),
|
|
820
822
|
],
|
|
821
823
|
},
|
|
822
824
|
{
|
package/src/cli/spec.js
CHANGED
|
@@ -571,6 +571,8 @@ const COMMAND_GROUPS = {
|
|
|
571
571
|
reports: { description: 'List mission reports', method: 'GET', path: '/missions/:id/reports', params: ['id'] },
|
|
572
572
|
'report-now': { description: 'Force-generate a mission report now', method: 'POST', path: '/missions/:id/reports', params: ['id'] },
|
|
573
573
|
events: { description: 'List mission events', method: 'GET', path: '/missions/:id/events', params: ['id'] },
|
|
574
|
+
templates: { description: 'List built-in mission templates', method: 'GET', path: '/missions/templates' },
|
|
575
|
+
instantiate: { description: 'Create a mission from a template', method: 'POST', path: '/missions/templates/:id/instantiate', params: ['id'], body: true },
|
|
574
576
|
},
|
|
575
577
|
},
|
|
576
578
|
}
|
|
@@ -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,278 @@
|
|
|
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
|
+
export function MissionTemplateInstallDialog({ template, sessions, onClose, onInstall }: Props) {
|
|
47
|
+
const [title, setTitle] = useState('')
|
|
48
|
+
const [goal, setGoal] = useState('')
|
|
49
|
+
const [criteriaText, setCriteriaText] = useState('')
|
|
50
|
+
const [rootSessionId, setRootSessionId] = useState('')
|
|
51
|
+
const [advancedOpen, setAdvancedOpen] = useState(false)
|
|
52
|
+
const [maxUsd, setMaxUsd] = useState('')
|
|
53
|
+
const [maxTokens, setMaxTokens] = useState('')
|
|
54
|
+
const [maxWallclockSec, setMaxWallclockSec] = useState('')
|
|
55
|
+
const [maxTurns, setMaxTurns] = useState('')
|
|
56
|
+
const [reportsEnabled, setReportsEnabled] = useState(true)
|
|
57
|
+
const [reportIntervalMin, setReportIntervalMin] = useState('')
|
|
58
|
+
const [busy, setBusy] = useState(false)
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!template) return
|
|
62
|
+
setTitle(template.defaults.title)
|
|
63
|
+
setGoal(template.defaults.goal)
|
|
64
|
+
setCriteriaText(template.defaults.successCriteria.join('\n'))
|
|
65
|
+
setMaxUsd(template.defaults.budget.maxUsd != null ? String(template.defaults.budget.maxUsd) : '')
|
|
66
|
+
setMaxTokens(template.defaults.budget.maxTokens != null ? String(template.defaults.budget.maxTokens) : '')
|
|
67
|
+
setMaxWallclockSec(template.defaults.budget.maxWallclockSec != null ? String(template.defaults.budget.maxWallclockSec) : '')
|
|
68
|
+
setMaxTurns(template.defaults.budget.maxTurns != null ? String(template.defaults.budget.maxTurns) : '')
|
|
69
|
+
setReportsEnabled(template.defaults.reportSchedule?.enabled ?? false)
|
|
70
|
+
setReportIntervalMin(
|
|
71
|
+
template.defaults.reportSchedule?.intervalSec != null
|
|
72
|
+
? String(Math.round(template.defaults.reportSchedule.intervalSec / 60))
|
|
73
|
+
: '60',
|
|
74
|
+
)
|
|
75
|
+
setAdvancedOpen(false)
|
|
76
|
+
}, [template])
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!rootSessionId && sessions.length > 0) setRootSessionId(sessions[0].id)
|
|
80
|
+
}, [sessions, rootSessionId])
|
|
81
|
+
|
|
82
|
+
const badges = useMemo(() => {
|
|
83
|
+
if (!template) return []
|
|
84
|
+
const out: string[] = []
|
|
85
|
+
if (template.defaults.budget.maxUsd != null) out.push(`$${template.defaults.budget.maxUsd} cap`)
|
|
86
|
+
if (template.defaults.budget.maxTurns != null) out.push(`${template.defaults.budget.maxTurns} turns`)
|
|
87
|
+
if (template.defaults.budget.maxWallclockSec != null) out.push(formatDuration(template.defaults.budget.maxWallclockSec))
|
|
88
|
+
if (template.defaults.reportSchedule) out.push(`Reports every ${formatDuration(template.defaults.reportSchedule.intervalSec)}`)
|
|
89
|
+
return out
|
|
90
|
+
}, [template])
|
|
91
|
+
|
|
92
|
+
if (!template) return null
|
|
93
|
+
|
|
94
|
+
const submit = async () => {
|
|
95
|
+
if (!rootSessionId) {
|
|
96
|
+
toast.error('Pick a session to drive this mission')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (!title.trim() || !goal.trim()) {
|
|
100
|
+
toast.error('Title and goal are required')
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
setBusy(true)
|
|
104
|
+
try {
|
|
105
|
+
const successCriteria = criteriaText.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
106
|
+
const intervalMin = numOrNull(reportIntervalMin) ?? 60
|
|
107
|
+
await onInstall(template, {
|
|
108
|
+
rootSessionId,
|
|
109
|
+
overrides: {
|
|
110
|
+
title: title.trim(),
|
|
111
|
+
goal: goal.trim(),
|
|
112
|
+
successCriteria,
|
|
113
|
+
budget: {
|
|
114
|
+
maxUsd: numOrNull(maxUsd),
|
|
115
|
+
maxTokens: numOrNull(maxTokens),
|
|
116
|
+
maxWallclockSec: numOrNull(maxWallclockSec),
|
|
117
|
+
maxTurns: numOrNull(maxTurns),
|
|
118
|
+
},
|
|
119
|
+
reportSchedule: reportsEnabled
|
|
120
|
+
? { intervalSec: Math.round(intervalMin * 60), format: 'markdown', enabled: true }
|
|
121
|
+
: null,
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
onClose()
|
|
125
|
+
} catch (error) {
|
|
126
|
+
toast.error(`Install failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
127
|
+
} finally {
|
|
128
|
+
setBusy(false)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
134
|
+
<div
|
|
135
|
+
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"
|
|
136
|
+
onClick={(e) => e.stopPropagation()}
|
|
137
|
+
>
|
|
138
|
+
<div className="flex items-start gap-3 mb-4">
|
|
139
|
+
<span className="text-[28px] leading-none" aria-hidden>{template.icon}</span>
|
|
140
|
+
<div className="min-w-0 flex-1">
|
|
141
|
+
<div className="text-[15px] font-700 text-text">{template.name}</div>
|
|
142
|
+
<div className="text-[12px] text-text-3 leading-[1.5] mt-1">{template.description}</div>
|
|
143
|
+
{badges.length > 0 && (
|
|
144
|
+
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
|
145
|
+
{badges.map((badge) => (
|
|
146
|
+
<span
|
|
147
|
+
key={badge}
|
|
148
|
+
className="text-[10px] font-600 px-1.5 py-0.5 rounded border border-white/[0.08] bg-white/[0.02] text-text-3"
|
|
149
|
+
>
|
|
150
|
+
{badge}
|
|
151
|
+
</span>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{template.setupNote && (
|
|
159
|
+
<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]">
|
|
160
|
+
<span className="font-700">Setup: </span>
|
|
161
|
+
{template.setupNote}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
<div className="flex flex-col gap-3">
|
|
166
|
+
<label className="flex flex-col gap-1">
|
|
167
|
+
<span className="text-[11px] text-text-3">Title</span>
|
|
168
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} className={inputClass} />
|
|
169
|
+
</label>
|
|
170
|
+
|
|
171
|
+
<label className="flex flex-col gap-1">
|
|
172
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
173
|
+
Goal <HintTip text="The natural-language objective your team will work on." />
|
|
174
|
+
</span>
|
|
175
|
+
<textarea
|
|
176
|
+
value={goal}
|
|
177
|
+
onChange={(e) => setGoal(e.target.value)}
|
|
178
|
+
rows={4}
|
|
179
|
+
className={`${inputClass} resize-none`}
|
|
180
|
+
/>
|
|
181
|
+
</label>
|
|
182
|
+
|
|
183
|
+
<label className="flex flex-col gap-1">
|
|
184
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
185
|
+
Root session <HintTip text="The session whose heartbeat drives this mission." />
|
|
186
|
+
</span>
|
|
187
|
+
<select value={rootSessionId} onChange={(e) => setRootSessionId(e.target.value)} className={inputClass}>
|
|
188
|
+
{sessions.length === 0 && <option value="">No sessions available. Create a chat first.</option>}
|
|
189
|
+
{sessions.map((s) => (
|
|
190
|
+
<option key={s.id} value={s.id}>
|
|
191
|
+
{s.name || s.id}
|
|
192
|
+
</option>
|
|
193
|
+
))}
|
|
194
|
+
</select>
|
|
195
|
+
</label>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div className="mt-4">
|
|
199
|
+
<AdvancedSettingsSection
|
|
200
|
+
open={advancedOpen}
|
|
201
|
+
onToggle={() => setAdvancedOpen((o) => !o)}
|
|
202
|
+
summary="Budgets, criteria, reports"
|
|
203
|
+
>
|
|
204
|
+
<div className="flex flex-col gap-4">
|
|
205
|
+
<label className="flex flex-col gap-1">
|
|
206
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
207
|
+
Success criteria <HintTip text="One per line. Used in reports and final verification." />
|
|
208
|
+
</span>
|
|
209
|
+
<textarea
|
|
210
|
+
value={criteriaText}
|
|
211
|
+
onChange={(e) => setCriteriaText(e.target.value)}
|
|
212
|
+
rows={4}
|
|
213
|
+
className={`${inputClass} resize-none`}
|
|
214
|
+
/>
|
|
215
|
+
</label>
|
|
216
|
+
|
|
217
|
+
<div className="grid grid-cols-2 gap-3">
|
|
218
|
+
<label className="flex flex-col gap-1">
|
|
219
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
220
|
+
Max USD <HintTip text="Hard spend cap. Leave blank for no limit." />
|
|
221
|
+
</span>
|
|
222
|
+
<input value={maxUsd} onChange={(e) => setMaxUsd(e.target.value)} className={inputClass} inputMode="decimal" />
|
|
223
|
+
</label>
|
|
224
|
+
<label className="flex flex-col gap-1">
|
|
225
|
+
<span className="text-[11px] text-text-3">Max tokens</span>
|
|
226
|
+
<input value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} className={inputClass} inputMode="numeric" />
|
|
227
|
+
</label>
|
|
228
|
+
<label className="flex flex-col gap-1">
|
|
229
|
+
<span className="text-[11px] text-text-3 inline-flex items-center gap-1">
|
|
230
|
+
Max wallclock (sec) <HintTip text="Scheduler aborts after this many seconds of elapsed wallclock." />
|
|
231
|
+
</span>
|
|
232
|
+
<input value={maxWallclockSec} onChange={(e) => setMaxWallclockSec(e.target.value)} className={inputClass} inputMode="numeric" />
|
|
233
|
+
</label>
|
|
234
|
+
<label className="flex flex-col gap-1">
|
|
235
|
+
<span className="text-[11px] text-text-3">Max turns</span>
|
|
236
|
+
<input value={maxTurns} onChange={(e) => setMaxTurns(e.target.value)} className={inputClass} inputMode="numeric" />
|
|
237
|
+
</label>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
|
|
241
|
+
<div className="text-[11px] font-600 text-text-3 uppercase tracking-wide mb-1.5">Periodic reports</div>
|
|
242
|
+
<label className="flex items-center gap-2 flex-wrap">
|
|
243
|
+
<input type="checkbox" checked={reportsEnabled} onChange={(e) => setReportsEnabled(e.target.checked)} />
|
|
244
|
+
<span className="text-[11px] text-text-3">Send a markdown progress report every</span>
|
|
245
|
+
<input
|
|
246
|
+
disabled={!reportsEnabled}
|
|
247
|
+
value={reportIntervalMin}
|
|
248
|
+
onChange={(e) => setReportIntervalMin(e.target.value)}
|
|
249
|
+
className={`${inputClass} w-16`}
|
|
250
|
+
inputMode="numeric"
|
|
251
|
+
/>
|
|
252
|
+
<span className="text-[11px] text-text-3">minutes</span>
|
|
253
|
+
</label>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</AdvancedSettingsSection>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div className="mt-5 flex items-center justify-end gap-2">
|
|
260
|
+
<button
|
|
261
|
+
onClick={onClose}
|
|
262
|
+
className="text-[12px] px-3 py-1.5 rounded border border-white/[0.08] hover:bg-white/[0.04]"
|
|
263
|
+
disabled={busy}
|
|
264
|
+
>
|
|
265
|
+
Cancel
|
|
266
|
+
</button>
|
|
267
|
+
<button
|
|
268
|
+
onClick={submit}
|
|
269
|
+
disabled={busy}
|
|
270
|
+
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"
|
|
271
|
+
>
|
|
272
|
+
{busy ? 'Installing…' : 'Install mission'}
|
|
273
|
+
</button>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
@@ -208,19 +208,24 @@ function syncSessionFromAgent(sessionId: string): void {
|
|
|
208
208
|
}
|
|
209
209
|
if (route) {
|
|
210
210
|
const resolved = applyResolvedRoute({ ...session }, route)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
211
|
+
// Do NOT sync provider/model from the route here — the user may have manually
|
|
212
|
+
// switched the session model, and we must preserve that choice.
|
|
213
|
+
// Provider/model are initialized from the route at session-creation time only.
|
|
214
|
+
// Only sync credentials/endpoint when the session's provider still matches the
|
|
215
|
+
// route's provider — if the user switched providers, leave their credential alone.
|
|
216
|
+
if (session.provider === resolved.provider) {
|
|
217
|
+
if ((session.credentialId || null) !== (resolved.credentialId || null)) {
|
|
218
|
+
session.credentialId = resolved.credentialId ?? null
|
|
219
|
+
changed = true
|
|
220
|
+
}
|
|
221
|
+
if (JSON.stringify(session.fallbackCredentialIds || []) !== JSON.stringify(resolved.fallbackCredentialIds || [])) {
|
|
222
|
+
session.fallbackCredentialIds = [...(resolved.fallbackCredentialIds || [])]
|
|
223
|
+
changed = true
|
|
224
|
+
}
|
|
225
|
+
if ((session.apiEndpoint || null) !== (resolved.apiEndpoint || null)) {
|
|
226
|
+
session.apiEndpoint = resolved.apiEndpoint ?? null
|
|
227
|
+
changed = true
|
|
228
|
+
}
|
|
224
229
|
}
|
|
225
230
|
if ((session.gatewayProfileId || null) !== (resolved.gatewayProfileId || null)) {
|
|
226
231
|
session.gatewayProfileId = resolved.gatewayProfileId ?? null
|
|
@@ -624,9 +629,15 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
|
|
|
624
629
|
preferredGatewayTags: session.routePreferredGatewayTags || [],
|
|
625
630
|
preferredGatewayUseCase: session.routePreferredGatewayUseCase || null,
|
|
626
631
|
})
|
|
627
|
-
if (preferredRoute) {
|
|
632
|
+
if (preferredRoute && sessionForRun.provider === preferredRoute.provider) {
|
|
633
|
+
// Apply route for credentials/endpoint/gateway, but preserve the user's
|
|
634
|
+
// manually-selected model — only sync infra, not the model choice.
|
|
635
|
+
const savedModel = sessionForRun.model
|
|
628
636
|
sessionForRun = applyResolvedRoute({ ...sessionForRun }, preferredRoute)
|
|
637
|
+
sessionForRun = { ...sessionForRun, model: savedModel }
|
|
629
638
|
}
|
|
639
|
+
// If the user has manually switched to a different provider, skip the route
|
|
640
|
+
// entirely — the session already has the correct provider/model/credential.
|
|
630
641
|
}
|
|
631
642
|
let effectiveMessage = message
|
|
632
643
|
|
|
@@ -7,6 +7,7 @@ import { buildAgentDisabledMessage, isAgentDisabled } from '@/lib/server/agents/
|
|
|
7
7
|
import { loadAgent } from '@/lib/server/agents/agent-repository'
|
|
8
8
|
import { clearMainLoopStateForSession } from '@/lib/server/agents/main-agent-loop'
|
|
9
9
|
import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
|
|
10
|
+
import { loadCredentials } from '@/lib/server/credentials/credential-repository'
|
|
10
11
|
import { cleanupSessionProcesses } from '@/lib/server/runtime/process-manager'
|
|
11
12
|
import { stopActiveSessionProcess } from '@/lib/server/runtime/runtime-state'
|
|
12
13
|
import {
|
|
@@ -235,6 +236,13 @@ export function updateChatSession(sessionId: string, updates: Record<string, unk
|
|
|
235
236
|
if (updates.credentialId !== undefined) session.credentialId = updates.credentialId
|
|
236
237
|
else if (agentIdUpdateProvided && linkedRoute) session.credentialId = linkedRoute.credentialId ?? null
|
|
237
238
|
else if (agentIdUpdateProvided && linkedAgent) session.credentialId = linkedAgent.credentialId ?? null
|
|
239
|
+
else if (updates.provider !== undefined && updates.provider !== session.provider) {
|
|
240
|
+
// Provider changed without an explicit credentialId — find a stored credential
|
|
241
|
+
// for the new provider so API-key-based providers (Groq, OpenAI, …) work.
|
|
242
|
+
const allCreds = loadCredentials()
|
|
243
|
+
const providerCred = Object.values(allCreds).find(c => c.provider === updates.provider)
|
|
244
|
+
session.credentialId = providerCred?.id ?? null
|
|
245
|
+
}
|
|
238
246
|
if (updates.fallbackCredentialIds !== undefined) session.fallbackCredentialIds = updates.fallbackCredentialIds
|
|
239
247
|
else if (agentIdUpdateProvided && linkedRoute) session.fallbackCredentialIds = [...linkedRoute.fallbackCredentialIds]
|
|
240
248
|
if (updates.gatewayProfileId !== undefined) session.gatewayProfileId = updates.gatewayProfileId
|
|
@@ -264,6 +272,9 @@ export function updateChatSession(sessionId: string, updates: Record<string, unk
|
|
|
264
272
|
session.apiEndpoint = linkedRoute.apiEndpoint ?? null
|
|
265
273
|
} else if (agentIdUpdateProvided && linkedAgent) {
|
|
266
274
|
session.apiEndpoint = normalizeProviderEndpoint(linkedAgent.provider, linkedAgent.apiEndpoint ?? null)
|
|
275
|
+
} else if (updates.provider !== undefined && updates.provider !== session.provider) {
|
|
276
|
+
// Provider changed — clear stale endpoint so the new provider uses its own default.
|
|
277
|
+
session.apiEndpoint = null
|
|
267
278
|
}
|
|
268
279
|
if (updates.heartbeatEnabled !== undefined) session.heartbeatEnabled = updates.heartbeatEnabled
|
|
269
280
|
if (updates.heartbeatIntervalSec !== undefined) session.heartbeatIntervalSec = updates.heartbeatIntervalSec
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from 'crypto'
|
|
2
|
-
import type { Mission, MissionBudget, MissionReportSchedule, MissionStatus } from '@/types'
|
|
2
|
+
import type { Mission, MissionBudget, MissionReportSchedule, MissionStatus, MissionTemplate } from '@/types'
|
|
3
3
|
import { DEFAULT_MISSION_WARN_FRACTIONS } from '@/types'
|
|
4
4
|
import { hmrSingleton } from '@/lib/shared-utils'
|
|
5
5
|
import { log } from '@/lib/server/logger'
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
patchMission,
|
|
12
12
|
upsertMission,
|
|
13
13
|
} from './mission-repository'
|
|
14
|
+
import { getMissionTemplate } from './mission-templates'
|
|
14
15
|
|
|
15
16
|
const TAG = 'mission-service'
|
|
16
17
|
|
|
@@ -59,6 +60,7 @@ export interface CreateMissionInput {
|
|
|
59
60
|
budget?: Partial<MissionBudget>
|
|
60
61
|
reportSchedule?: MissionReportSchedule | null
|
|
61
62
|
reportConnectorIds?: string[]
|
|
63
|
+
templateId?: string | null
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
function newMissionId(): string {
|
|
@@ -82,6 +84,9 @@ function sanitizeBudget(input: Partial<MissionBudget> = {}): MissionBudget {
|
|
|
82
84
|
|
|
83
85
|
export function createMission(input: CreateMissionInput): Mission {
|
|
84
86
|
const now = Date.now()
|
|
87
|
+
const templateId = typeof input.templateId === 'string' && input.templateId.trim()
|
|
88
|
+
? input.templateId.trim().slice(0, 64)
|
|
89
|
+
: null
|
|
85
90
|
const mission: Mission = {
|
|
86
91
|
id: newMissionId(),
|
|
87
92
|
title: input.title.trim(),
|
|
@@ -106,12 +111,53 @@ export function createMission(input: CreateMissionInput): Mission {
|
|
|
106
111
|
reportConnectorIds: input.reportConnectorIds ?? [],
|
|
107
112
|
createdAt: now,
|
|
108
113
|
updatedAt: now,
|
|
114
|
+
templateId,
|
|
109
115
|
}
|
|
110
116
|
upsertMission(mission)
|
|
111
117
|
log.info(TAG, `Created mission ${mission.id} (goal: ${mission.goal.slice(0, 80)})`)
|
|
112
118
|
return mission
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
export interface CreateMissionFromTemplateInput {
|
|
122
|
+
templateId: string
|
|
123
|
+
rootSessionId: string
|
|
124
|
+
overrides?: {
|
|
125
|
+
title?: string
|
|
126
|
+
goal?: string
|
|
127
|
+
successCriteria?: string[]
|
|
128
|
+
budget?: Partial<MissionBudget>
|
|
129
|
+
reportSchedule?: MissionReportSchedule | null
|
|
130
|
+
agentIds?: string[]
|
|
131
|
+
reportConnectorIds?: string[]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface CreateMissionFromTemplateResult {
|
|
136
|
+
mission: Mission
|
|
137
|
+
template: MissionTemplate
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createMissionFromTemplate(input: CreateMissionFromTemplateInput): CreateMissionFromTemplateResult | null {
|
|
141
|
+
const template = getMissionTemplate(input.templateId)
|
|
142
|
+
if (!template) return null
|
|
143
|
+
const overrides = input.overrides ?? {}
|
|
144
|
+
const mergedBudget: Partial<MissionBudget> = { ...template.defaults.budget, ...(overrides.budget ?? {}) }
|
|
145
|
+
const mission = createMission({
|
|
146
|
+
title: overrides.title?.trim() || template.defaults.title,
|
|
147
|
+
goal: overrides.goal?.trim() || template.defaults.goal,
|
|
148
|
+
successCriteria: overrides.successCriteria ?? template.defaults.successCriteria,
|
|
149
|
+
rootSessionId: input.rootSessionId,
|
|
150
|
+
agentIds: overrides.agentIds ?? [],
|
|
151
|
+
budget: mergedBudget,
|
|
152
|
+
reportSchedule: overrides.reportSchedule === undefined
|
|
153
|
+
? template.defaults.reportSchedule
|
|
154
|
+
: overrides.reportSchedule,
|
|
155
|
+
reportConnectorIds: overrides.reportConnectorIds ?? [],
|
|
156
|
+
templateId: template.id,
|
|
157
|
+
})
|
|
158
|
+
return { mission, template }
|
|
159
|
+
}
|
|
160
|
+
|
|
115
161
|
function applyStatusTransition(
|
|
116
162
|
id: string,
|
|
117
163
|
next: MissionStatus,
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { after, before, describe, it } from 'node:test'
|
|
6
|
+
|
|
7
|
+
const originalEnv = {
|
|
8
|
+
DATA_DIR: process.env.DATA_DIR,
|
|
9
|
+
WORKSPACE_DIR: process.env.WORKSPACE_DIR,
|
|
10
|
+
SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let tempDir = ''
|
|
14
|
+
let templates: typeof import('./mission-templates')
|
|
15
|
+
let service: typeof import('./mission-service')
|
|
16
|
+
|
|
17
|
+
before(async () => {
|
|
18
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-mission-tpl-'))
|
|
19
|
+
process.env.DATA_DIR = path.join(tempDir, 'data')
|
|
20
|
+
process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
|
|
21
|
+
process.env.SWARMCLAW_BUILD_MODE = '1'
|
|
22
|
+
templates = await import('./mission-templates')
|
|
23
|
+
service = await import('./mission-service')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
after(() => {
|
|
27
|
+
if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
|
|
28
|
+
else process.env.DATA_DIR = originalEnv.DATA_DIR
|
|
29
|
+
if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
|
|
30
|
+
else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
|
|
31
|
+
if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
|
|
32
|
+
else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
|
|
33
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('mission-templates: registry', () => {
|
|
37
|
+
it('ships at least 6 built-in templates', () => {
|
|
38
|
+
const list = templates.listMissionTemplates()
|
|
39
|
+
assert.ok(list.length >= 6, `expected 6+ templates, got ${list.length}`)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('has no duplicate template ids', () => {
|
|
43
|
+
const list = templates.listMissionTemplates()
|
|
44
|
+
const ids = new Set<string>()
|
|
45
|
+
for (const t of list) {
|
|
46
|
+
assert.ok(!ids.has(t.id), `duplicate template id: ${t.id}`)
|
|
47
|
+
ids.add(t.id)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('every template has required fields populated', () => {
|
|
52
|
+
const list = templates.listMissionTemplates()
|
|
53
|
+
for (const t of list) {
|
|
54
|
+
assert.ok(t.id, `missing id`)
|
|
55
|
+
assert.ok(t.name, `${t.id} missing name`)
|
|
56
|
+
assert.ok(t.description, `${t.id} missing description`)
|
|
57
|
+
assert.ok(t.icon, `${t.id} missing icon`)
|
|
58
|
+
assert.ok(t.category, `${t.id} missing category`)
|
|
59
|
+
assert.ok(Array.isArray(t.tags), `${t.id} tags not array`)
|
|
60
|
+
assert.ok(t.defaults.title, `${t.id} missing defaults.title`)
|
|
61
|
+
assert.ok(t.defaults.goal, `${t.id} missing defaults.goal`)
|
|
62
|
+
assert.ok(Array.isArray(t.defaults.successCriteria), `${t.id} successCriteria not array`)
|
|
63
|
+
assert.ok(t.defaults.budget, `${t.id} missing budget`)
|
|
64
|
+
assert.ok(Array.isArray(t.defaults.budget.warnAtFractions), `${t.id} budget.warnAtFractions missing`)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('getMissionTemplate resolves known ids', () => {
|
|
69
|
+
const list = templates.listMissionTemplates()
|
|
70
|
+
const first = list[0]
|
|
71
|
+
const resolved = templates.getMissionTemplate(first.id)
|
|
72
|
+
assert.equal(resolved?.id, first.id)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('getMissionTemplate returns null for unknown ids', () => {
|
|
76
|
+
assert.equal(templates.getMissionTemplate('nope-does-not-exist'), null)
|
|
77
|
+
assert.equal(templates.getMissionTemplate(''), null)
|
|
78
|
+
assert.equal(templates.getMissionTemplate(null), null)
|
|
79
|
+
assert.equal(templates.getMissionTemplate(undefined), null)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('mission-service: createMissionFromTemplate', () => {
|
|
84
|
+
it('materializes a mission using template defaults', () => {
|
|
85
|
+
const list = templates.listMissionTemplates()
|
|
86
|
+
const tpl = list[0]
|
|
87
|
+
const result = service.createMissionFromTemplate({
|
|
88
|
+
templateId: tpl.id,
|
|
89
|
+
rootSessionId: 'sess_test_1',
|
|
90
|
+
})
|
|
91
|
+
assert.ok(result, 'expected result')
|
|
92
|
+
assert.equal(result!.mission.templateId, tpl.id)
|
|
93
|
+
assert.equal(result!.mission.goal, tpl.defaults.goal)
|
|
94
|
+
assert.equal(result!.mission.title, tpl.defaults.title)
|
|
95
|
+
assert.equal(result!.mission.rootSessionId, 'sess_test_1')
|
|
96
|
+
assert.deepEqual(result!.mission.successCriteria, tpl.defaults.successCriteria)
|
|
97
|
+
assert.equal(result!.template.id, tpl.id)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('applies overrides without dropping unspecified defaults', () => {
|
|
101
|
+
const tpl = templates.listMissionTemplates()[0]
|
|
102
|
+
const result = service.createMissionFromTemplate({
|
|
103
|
+
templateId: tpl.id,
|
|
104
|
+
rootSessionId: 'sess_test_2',
|
|
105
|
+
overrides: {
|
|
106
|
+
title: 'Custom title',
|
|
107
|
+
budget: { maxUsd: 99 },
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
assert.ok(result)
|
|
111
|
+
assert.equal(result!.mission.title, 'Custom title')
|
|
112
|
+
assert.equal(result!.mission.budget.maxUsd, 99)
|
|
113
|
+
// Token cap from template should persist when not overridden
|
|
114
|
+
assert.equal(result!.mission.budget.maxTokens, tpl.defaults.budget.maxTokens)
|
|
115
|
+
// Goal unchanged
|
|
116
|
+
assert.equal(result!.mission.goal, tpl.defaults.goal)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('returns null for unknown template id', () => {
|
|
120
|
+
const result = service.createMissionFromTemplate({
|
|
121
|
+
templateId: 'no-such-template',
|
|
122
|
+
rootSessionId: 'sess_test_3',
|
|
123
|
+
})
|
|
124
|
+
assert.equal(result, null)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('allows overriding reportSchedule to null', () => {
|
|
128
|
+
const tpl = templates.listMissionTemplates()[0]
|
|
129
|
+
const result = service.createMissionFromTemplate({
|
|
130
|
+
templateId: tpl.id,
|
|
131
|
+
rootSessionId: 'sess_test_4',
|
|
132
|
+
overrides: { reportSchedule: null },
|
|
133
|
+
})
|
|
134
|
+
assert.ok(result)
|
|
135
|
+
assert.equal(result!.mission.reportSchedule, null)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('mission-service: templateId persistence', () => {
|
|
140
|
+
it('createMission persists an explicit templateId', () => {
|
|
141
|
+
const mission = service.createMission({
|
|
142
|
+
title: 'Direct',
|
|
143
|
+
goal: 'no template',
|
|
144
|
+
rootSessionId: 'sess_direct',
|
|
145
|
+
templateId: 'some-template-id',
|
|
146
|
+
})
|
|
147
|
+
assert.equal(mission.templateId, 'some-template-id')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('createMission defaults templateId to null when omitted', () => {
|
|
151
|
+
const mission = service.createMission({
|
|
152
|
+
title: 'Direct',
|
|
153
|
+
goal: 'no template',
|
|
154
|
+
rootSessionId: 'sess_direct_2',
|
|
155
|
+
})
|
|
156
|
+
assert.equal(mission.templateId, null)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// Normalization test: legacy mission JSON without templateId should get templateId: null after load.
|
|
161
|
+
describe('mission normalization: templateId default', () => {
|
|
162
|
+
const loadItem = () => null
|
|
163
|
+
|
|
164
|
+
it('legacy records normalize to templateId: null', async () => {
|
|
165
|
+
const { normalizeStoredRecord } = await import('@/lib/server/storage-normalization')
|
|
166
|
+
const legacy: Record<string, unknown> = {
|
|
167
|
+
id: 'mi_legacy_1',
|
|
168
|
+
title: 'Legacy',
|
|
169
|
+
goal: 'from before templates',
|
|
170
|
+
successCriteria: [],
|
|
171
|
+
rootSessionId: 'sess_legacy',
|
|
172
|
+
agentIds: [],
|
|
173
|
+
status: 'draft',
|
|
174
|
+
budget: {},
|
|
175
|
+
usage: {},
|
|
176
|
+
milestones: [],
|
|
177
|
+
reportConnectorIds: [],
|
|
178
|
+
createdAt: Date.now(),
|
|
179
|
+
updatedAt: Date.now(),
|
|
180
|
+
}
|
|
181
|
+
const { value } = normalizeStoredRecord('agent_missions', legacy, loadItem)
|
|
182
|
+
const normalized = value as { templateId: unknown }
|
|
183
|
+
assert.equal(normalized.templateId, null)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('normalization preserves a valid templateId', async () => {
|
|
187
|
+
const { normalizeStoredRecord } = await import('@/lib/server/storage-normalization')
|
|
188
|
+
const record: Record<string, unknown> = {
|
|
189
|
+
id: 'mi_tpl_1',
|
|
190
|
+
title: 'From template',
|
|
191
|
+
goal: 'x',
|
|
192
|
+
successCriteria: [],
|
|
193
|
+
rootSessionId: 'sess_tpl',
|
|
194
|
+
agentIds: [],
|
|
195
|
+
status: 'draft',
|
|
196
|
+
budget: {},
|
|
197
|
+
usage: {},
|
|
198
|
+
milestones: [],
|
|
199
|
+
reportConnectorIds: [],
|
|
200
|
+
createdAt: Date.now(),
|
|
201
|
+
updatedAt: Date.now(),
|
|
202
|
+
templateId: 'daily-news-digest',
|
|
203
|
+
}
|
|
204
|
+
const { value } = normalizeStoredRecord('agent_missions', record, loadItem)
|
|
205
|
+
const normalized = value as { templateId: unknown }
|
|
206
|
+
assert.equal(normalized.templateId, 'daily-news-digest')
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MissionBudget,
|
|
3
|
+
MissionReportSchedule,
|
|
4
|
+
MissionTemplate,
|
|
5
|
+
MissionTemplateCategory,
|
|
6
|
+
} from '@/types'
|
|
7
|
+
import { DEFAULT_MISSION_WARN_FRACTIONS } from '@/types'
|
|
8
|
+
|
|
9
|
+
const HOUR = 3600
|
|
10
|
+
const DAY = 86_400
|
|
11
|
+
|
|
12
|
+
function budget(overrides: Partial<MissionBudget>): MissionBudget {
|
|
13
|
+
return {
|
|
14
|
+
maxUsd: null,
|
|
15
|
+
maxTokens: null,
|
|
16
|
+
maxToolCalls: null,
|
|
17
|
+
maxWallclockSec: null,
|
|
18
|
+
maxTurns: null,
|
|
19
|
+
warnAtFractions: DEFAULT_MISSION_WARN_FRACTIONS,
|
|
20
|
+
...overrides,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function report(intervalSec: number, format: MissionReportSchedule['format'] = 'markdown'): MissionReportSchedule {
|
|
25
|
+
return { intervalSec, format, enabled: true, lastReportAt: null }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const BUILT_IN_MISSION_TEMPLATES: MissionTemplate[] = [
|
|
29
|
+
{
|
|
30
|
+
id: 'daily-news-digest',
|
|
31
|
+
name: 'Daily News Digest',
|
|
32
|
+
description:
|
|
33
|
+
'Scan news sources, pick the 5 most relevant stories for your interests, and write a short digest once a day.',
|
|
34
|
+
icon: '📰',
|
|
35
|
+
category: 'research',
|
|
36
|
+
tags: ['daily', 'news', 'summary'],
|
|
37
|
+
setupNote:
|
|
38
|
+
'Edit the goal to list your interests and sources before starting (e.g., "AI infrastructure, open-source agents, Bloomberg / Hacker News").',
|
|
39
|
+
defaults: {
|
|
40
|
+
title: 'Daily News Digest',
|
|
41
|
+
goal:
|
|
42
|
+
'Every day, scan the latest news from my sources of interest, pick the 5 most relevant stories, and write a short markdown digest with title, 2-sentence summary, and link for each.',
|
|
43
|
+
successCriteria: [
|
|
44
|
+
'Exactly 5 stories per digest',
|
|
45
|
+
'Each story has a title, summary, and source link',
|
|
46
|
+
'Digest is less than 500 words',
|
|
47
|
+
],
|
|
48
|
+
budget: budget({ maxUsd: 1, maxTokens: 40_000, maxTurns: 80, maxWallclockSec: 2 * HOUR }),
|
|
49
|
+
reportSchedule: report(DAY),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'inbox-triage',
|
|
54
|
+
name: 'Inbox Triage',
|
|
55
|
+
description:
|
|
56
|
+
'Classify new emails, draft replies to routine threads, and flag anything that needs your attention.',
|
|
57
|
+
icon: '📬',
|
|
58
|
+
category: 'communication',
|
|
59
|
+
tags: ['email', 'triage', 'automation'],
|
|
60
|
+
setupNote:
|
|
61
|
+
'Connect your email connector and confirm send/reply permissions before starting this mission.',
|
|
62
|
+
defaults: {
|
|
63
|
+
title: 'Inbox Triage',
|
|
64
|
+
goal:
|
|
65
|
+
'Every hour, pull new emails, classify each as routine / needs-reply / urgent, draft replies to routine threads for my approval, and surface urgent items to me immediately.',
|
|
66
|
+
successCriteria: [
|
|
67
|
+
'Every new email is classified',
|
|
68
|
+
'Routine replies are drafted, not sent without approval',
|
|
69
|
+
'Urgent items are flagged within the same hour they arrive',
|
|
70
|
+
],
|
|
71
|
+
budget: budget({ maxUsd: 3, maxTokens: 120_000, maxTurns: 200, maxWallclockSec: 12 * HOUR }),
|
|
72
|
+
reportSchedule: report(6 * HOUR),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'competitor-watch',
|
|
77
|
+
name: 'Competitor Watch',
|
|
78
|
+
description:
|
|
79
|
+
'Track competitor websites, blogs, and releases; flag anything new and write a weekly summary.',
|
|
80
|
+
icon: '🔭',
|
|
81
|
+
category: 'monitoring',
|
|
82
|
+
tags: ['competitive', 'weekly', 'monitoring'],
|
|
83
|
+
setupNote:
|
|
84
|
+
'List the competitors and specific URLs to watch in the goal field before starting.',
|
|
85
|
+
defaults: {
|
|
86
|
+
title: 'Competitor Watch',
|
|
87
|
+
goal:
|
|
88
|
+
'Check the listed competitors every 6 hours for product releases, pricing changes, blog posts, and notable social activity. Write a short summary of new signals and compile a weekly roll-up.',
|
|
89
|
+
successCriteria: [
|
|
90
|
+
'All listed competitors are checked every cycle',
|
|
91
|
+
'New signals are captured with source links and timestamps',
|
|
92
|
+
'A weekly roll-up is produced on Monday mornings',
|
|
93
|
+
],
|
|
94
|
+
budget: budget({ maxUsd: 5, maxTokens: 200_000, maxTurns: 300, maxWallclockSec: 7 * DAY }),
|
|
95
|
+
reportSchedule: report(DAY),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'weekly-research-report',
|
|
100
|
+
name: 'Weekly Research Report',
|
|
101
|
+
description:
|
|
102
|
+
'Pick a research topic each Monday, dig into it across the week, and deliver a polished report by Friday.',
|
|
103
|
+
icon: '🧠',
|
|
104
|
+
category: 'research',
|
|
105
|
+
tags: ['weekly', 'research', 'report'],
|
|
106
|
+
setupNote:
|
|
107
|
+
'Set the topic in the goal (or let the agent pick from a rotating list).',
|
|
108
|
+
defaults: {
|
|
109
|
+
title: 'Weekly Research Report',
|
|
110
|
+
goal:
|
|
111
|
+
'Produce a 1000-2000 word research report on the assigned topic. Gather at least 8 sources, compare viewpoints, surface open questions, and deliver a polished markdown document by end of week.',
|
|
112
|
+
successCriteria: [
|
|
113
|
+
'Report is between 1000 and 2000 words',
|
|
114
|
+
'At least 8 distinct sources are cited with links',
|
|
115
|
+
'Conclusion explicitly lists open questions or areas for follow-up',
|
|
116
|
+
],
|
|
117
|
+
budget: budget({ maxUsd: 4, maxTokens: 250_000, maxTurns: 250, maxWallclockSec: 7 * DAY }),
|
|
118
|
+
reportSchedule: report(2 * DAY),
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'social-listener',
|
|
123
|
+
name: 'Social Listener',
|
|
124
|
+
description:
|
|
125
|
+
'Watch configured channels for mentions of your brand, keywords, or topics and surface notable threads.',
|
|
126
|
+
icon: '👂',
|
|
127
|
+
category: 'monitoring',
|
|
128
|
+
tags: ['social', 'listening', 'realtime'],
|
|
129
|
+
setupNote:
|
|
130
|
+
'Connect Discord or Slack (or both) and list the keywords to watch for in the goal.',
|
|
131
|
+
defaults: {
|
|
132
|
+
title: 'Social Listener',
|
|
133
|
+
goal:
|
|
134
|
+
'Watch the connected channels for the configured keywords. When a match appears, capture the message, author, timestamp, and a 1-sentence context note. Summarize daily.',
|
|
135
|
+
successCriteria: [
|
|
136
|
+
'Every keyword match is captured with context',
|
|
137
|
+
'No duplicate alerts for the same message',
|
|
138
|
+
'A daily recap is produced listing the top 10 mentions',
|
|
139
|
+
],
|
|
140
|
+
budget: budget({ maxUsd: 2, maxTokens: 100_000, maxTurns: 400, maxWallclockSec: 7 * DAY }),
|
|
141
|
+
reportSchedule: report(DAY),
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'customer-support-triage',
|
|
146
|
+
name: 'Customer Support Triage',
|
|
147
|
+
description:
|
|
148
|
+
'Classify incoming support tickets, draft first responses, and route complex issues to a human.',
|
|
149
|
+
icon: '🛟',
|
|
150
|
+
category: 'support',
|
|
151
|
+
tags: ['support', 'triage', 'drafts'],
|
|
152
|
+
setupNote:
|
|
153
|
+
'Connect your helpdesk or email connector and confirm draft-only permissions before starting.',
|
|
154
|
+
defaults: {
|
|
155
|
+
title: 'Customer Support Triage',
|
|
156
|
+
goal:
|
|
157
|
+
'For each new support ticket, classify priority and category, draft a first response for human review, and flag tickets that require engineering or account-level escalation.',
|
|
158
|
+
successCriteria: [
|
|
159
|
+
'Every ticket receives a draft within one hour',
|
|
160
|
+
'Priority and category are labeled consistently',
|
|
161
|
+
'Escalations are clearly flagged with a reason',
|
|
162
|
+
],
|
|
163
|
+
budget: budget({ maxUsd: 3, maxTokens: 150_000, maxTurns: 300, maxWallclockSec: 3 * DAY }),
|
|
164
|
+
reportSchedule: report(12 * HOUR),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
const TEMPLATE_INDEX: Map<string, MissionTemplate> = new Map(
|
|
170
|
+
BUILT_IN_MISSION_TEMPLATES.map((template) => [template.id, template]),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
export function listMissionTemplates(): MissionTemplate[] {
|
|
174
|
+
return BUILT_IN_MISSION_TEMPLATES.slice()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function getMissionTemplate(id: string | null | undefined): MissionTemplate | null {
|
|
178
|
+
if (!id || typeof id !== 'string') return null
|
|
179
|
+
return TEMPLATE_INDEX.get(id.trim()) ?? null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function listMissionTemplateCategories(): MissionTemplateCategory[] {
|
|
183
|
+
const seen = new Set<MissionTemplateCategory>()
|
|
184
|
+
for (const template of BUILT_IN_MISSION_TEMPLATES) seen.add(template.category)
|
|
185
|
+
return Array.from(seen)
|
|
186
|
+
}
|
|
@@ -458,6 +458,12 @@ function normalizeStoredAgentMissionRecord(value: unknown): unknown {
|
|
|
458
458
|
if (mission.endedAt === undefined) mission.endedAt = null
|
|
459
459
|
if (mission.endReason === undefined) mission.endReason = null
|
|
460
460
|
|
|
461
|
+
if (typeof mission.templateId === 'string' && mission.templateId.trim()) {
|
|
462
|
+
mission.templateId = mission.templateId.trim().slice(0, 64)
|
|
463
|
+
} else {
|
|
464
|
+
mission.templateId = null
|
|
465
|
+
}
|
|
466
|
+
|
|
461
467
|
return mission
|
|
462
468
|
}
|
|
463
469
|
|
package/src/types/mission.ts
CHANGED
|
@@ -109,7 +109,34 @@ export interface Mission {
|
|
|
109
109
|
startedAt?: number | null
|
|
110
110
|
endedAt?: number | null
|
|
111
111
|
endReason?: string | null
|
|
112
|
+
templateId?: string | null
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
export const DEFAULT_MISSION_WARN_FRACTIONS = [0.5, 0.8, 0.95]
|
|
115
116
|
export const MISSION_MILESTONE_TAIL_CAP = 200
|
|
117
|
+
|
|
118
|
+
export type MissionTemplateCategory =
|
|
119
|
+
| 'research'
|
|
120
|
+
| 'communication'
|
|
121
|
+
| 'monitoring'
|
|
122
|
+
| 'productivity'
|
|
123
|
+
| 'support'
|
|
124
|
+
|
|
125
|
+
export interface MissionTemplateDefaults {
|
|
126
|
+
title: string
|
|
127
|
+
goal: string
|
|
128
|
+
successCriteria: string[]
|
|
129
|
+
budget: MissionBudget
|
|
130
|
+
reportSchedule: MissionReportSchedule | null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface MissionTemplate {
|
|
134
|
+
id: string
|
|
135
|
+
name: string
|
|
136
|
+
description: string
|
|
137
|
+
icon: string
|
|
138
|
+
category: MissionTemplateCategory
|
|
139
|
+
tags: string[]
|
|
140
|
+
setupNote?: string | null
|
|
141
|
+
defaults: MissionTemplateDefaults
|
|
142
|
+
}
|