@bibobonking/opensol 3.1.2 → 3.1.4
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/CHANGELOG.md +16 -1
- package/dist/assets/index-B1pQC9Fe.js +288 -0
- package/dist/assets/index-qLr4aZVC.css +1 -0
- package/dist/favicon.svg +11 -5
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/public/favicon.svg +11 -5
- package/server/daemon.mjs +42 -4
- package/server/lib/deploy.mjs +78 -7
- package/server/lib/skills.mjs +128 -0
- package/server/lib/tools.mjs +40 -0
- package/src/agent/adapters/openai-responses.ts +5 -1
- package/src/agent/adapters/openai.ts +15 -2
- package/src/agent/prompt.ts +4 -7
- package/src/agent/tools.ts +1 -0
- package/src/components/DeployModal.tsx +77 -15
- package/src/components/FileIcon.tsx +108 -0
- package/src/components/HomeDashboard.tsx +9 -2
- package/src/components/Logo.tsx +11 -5
- package/src/components/ModelDeck.tsx +12 -2
- package/src/components/SettingsModal.tsx +144 -199
- package/src/components/Sidebar.tsx +4 -5
- package/src/components/SystemPromptModal.tsx +168 -0
- package/src/components/TopBar.tsx +19 -0
- package/src/components/WorkspacePanel.tsx +94 -46
- package/src/core/kernel.ts +2 -0
- package/src/core/plugins/tools/skill.ts +101 -0
- package/src/core/services/agent-loop.ts +26 -28
- package/src/core/services/system-prompt.ts +15 -13
- package/src/core/services/tools.ts +1 -0
- package/src/lib/daemon.ts +19 -0
- package/src/lib/permissions.ts +1 -1
- package/src/lib/retry.test.mjs +17 -0
- package/src/lib/retry.ts +13 -0
- package/src/lib/skills.test.mjs +22 -0
- package/src/lib/skills.ts +93 -0
- package/src/lib/usage.ts +12 -0
- package/src/store/chats.ts +1 -0
- package/src/store/settings.ts +21 -9
- package/src/types.ts +14 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-CeN54A2N.css +0 -1
- package/dist/assets/index-fhIO9a7_.js +0 -318
- package/src/data/skills.ts +0 -68
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
FolderOpen,
|
|
3
3
|
ListChecks,
|
|
4
4
|
Menu,
|
|
5
|
+
MessageSquareCode,
|
|
5
6
|
Moon,
|
|
6
7
|
PanelLeftOpen,
|
|
7
8
|
Rocket,
|
|
@@ -19,6 +20,8 @@ import { cn } from '../lib/utils'
|
|
|
19
20
|
import { themeIsDark } from '../lib/themes'
|
|
20
21
|
import { LogoMark } from './Logo'
|
|
21
22
|
import { ContextBar } from './ContextBar'
|
|
23
|
+
import { SystemPromptModal } from './SystemPromptModal'
|
|
24
|
+
import { useState } from 'react'
|
|
22
25
|
|
|
23
26
|
export type ChatViewMode = 'chat' | 'trajectory'
|
|
24
27
|
|
|
@@ -44,7 +47,11 @@ export function TopBar({
|
|
|
44
47
|
const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
|
|
45
48
|
const setDeployOpen = useUi((state) => state.setDeployOpen)
|
|
46
49
|
const workspace = useWorkspace((state) => state.info)
|
|
50
|
+
const workspaceRoot = useWorkspace((state) => state.root)
|
|
47
51
|
const activeModel = useActiveModel()
|
|
52
|
+
const [promptOpen, setPromptOpen] = useState(false)
|
|
53
|
+
const root = session?.workspace || workspaceRoot || ''
|
|
54
|
+
const promptIsCustom = Boolean(session?.systemPrompt?.trim() || (root && settings.workspacePrompts[root]?.trim()))
|
|
48
55
|
|
|
49
56
|
// Go through the theme registry so Night Owl, Claude Code, Solar and plugin themes count as dark.
|
|
50
57
|
const isDark = themeIsDark(
|
|
@@ -126,6 +133,17 @@ export function TopBar({
|
|
|
126
133
|
<ContextBar messages={session?.messages ?? []} model={activeModel} sessionId={session?.id ?? null} />
|
|
127
134
|
</div>
|
|
128
135
|
)}
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
className={cn('icon-button relative hidden sm:inline-flex', promptIsCustom && 'text-accent')}
|
|
139
|
+
onClick={() => setPromptOpen(true)}
|
|
140
|
+
disabled={!session}
|
|
141
|
+
title={promptIsCustom ? 'Custom system prompt for this session or workspace' : 'Edit the system prompt for this session'}
|
|
142
|
+
aria-label="Edit system prompt"
|
|
143
|
+
>
|
|
144
|
+
<MessageSquareCode size={16} />
|
|
145
|
+
{promptIsCustom && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-accent" />}
|
|
146
|
+
</button>
|
|
129
147
|
<button
|
|
130
148
|
type="button"
|
|
131
149
|
onClick={() => setWorkspaceOpen(!workspaceOpen)}
|
|
@@ -177,6 +195,7 @@ export function TopBar({
|
|
|
177
195
|
<Settings size={16} />
|
|
178
196
|
</button>
|
|
179
197
|
</div>
|
|
198
|
+
<SystemPromptModal session={session} open={promptOpen} onClose={() => setPromptOpen(false)} />
|
|
180
199
|
</header>
|
|
181
200
|
)
|
|
182
201
|
}
|
|
@@ -28,6 +28,7 @@ import { useWorkspace } from '../store/workspace'
|
|
|
28
28
|
import { PreviewPane } from './PreviewPane'
|
|
29
29
|
import { SessionsTab } from './SessionsTab'
|
|
30
30
|
import { useChats } from '../store/chats'
|
|
31
|
+
import { FileIcon } from './FileIcon'
|
|
31
32
|
import { Spinner } from './ui'
|
|
32
33
|
|
|
33
34
|
interface TreeNode {
|
|
@@ -248,6 +249,9 @@ export function WorkspacePanel() {
|
|
|
248
249
|
return entries.filter((entry) => entry.path.toLowerCase().includes(clean)).slice(0, 300)
|
|
249
250
|
}, [entries, query])
|
|
250
251
|
const lineCount = content ? content.split('\n').length : 0
|
|
252
|
+
const dirCount = entries.filter((entry) => entry.type === 'dir').length
|
|
253
|
+
const fileCount = entries.length - dirCount
|
|
254
|
+
const dirPaths = useMemo(() => entries.filter((entry) => entry.type === 'dir').map((entry) => entry.path), [entries])
|
|
251
255
|
|
|
252
256
|
useEffect(() => {
|
|
253
257
|
if (editor) editorRef.current?.focus()
|
|
@@ -318,44 +322,54 @@ export function WorkspacePanel() {
|
|
|
318
322
|
const closed = collapsed.has(path)
|
|
319
323
|
return (
|
|
320
324
|
<div key={path}>
|
|
321
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
setCollapsed((current) => {
|
|
325
|
-
const next = new Set(current)
|
|
326
|
-
if (next.has(path)) next.delete(path)
|
|
327
|
-
else next.add(path)
|
|
328
|
-
return next
|
|
329
|
-
})
|
|
330
|
-
}
|
|
331
|
-
className="group flex h-7 w-full items-center gap-1.5 rounded-md pr-1.5 text-left text-[11.5px] font-medium text-muted transition-colors hover:bg-raised hover:text-ink"
|
|
332
|
-
style={{ paddingLeft: 8 + depth * 13 }}
|
|
333
|
-
title={path}
|
|
325
|
+
<div
|
|
326
|
+
className="group/row relative flex h-7 items-center rounded-lg transition-colors hover:bg-raised/80"
|
|
327
|
+
style={{ paddingLeft: 6 + depth * 14 }}
|
|
334
328
|
>
|
|
335
|
-
<
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
329
|
+
<button
|
|
330
|
+
type="button"
|
|
331
|
+
onClick={() =>
|
|
332
|
+
setCollapsed((current) => {
|
|
333
|
+
const next = new Set(current)
|
|
334
|
+
if (next.has(path)) next.delete(path)
|
|
335
|
+
else next.add(path)
|
|
336
|
+
return next
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
className="flex min-w-0 flex-1 items-center gap-1.5 pr-1 text-left text-[11.5px] font-medium text-muted transition-colors hover:text-ink"
|
|
340
|
+
title={path}
|
|
341
|
+
aria-expanded={!closed}
|
|
342
|
+
>
|
|
343
|
+
<ChevronRight
|
|
344
|
+
size={10}
|
|
345
|
+
className={cn('shrink-0 text-faint transition-transform duration-150', !closed && 'rotate-90')}
|
|
346
|
+
/>
|
|
347
|
+
<FileIcon path={path} type="dir" open={!closed} size={13} />
|
|
348
|
+
<span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
|
|
349
|
+
<span className="shrink-0 pr-1 font-mono text-[8.5px] tabular-nums text-faint opacity-0 transition-opacity group-hover/row:opacity-100">
|
|
350
|
+
{node.children.length}
|
|
351
|
+
</span>
|
|
352
|
+
</button>
|
|
353
|
+
<button
|
|
354
|
+
type="button"
|
|
355
|
+
onClick={() => void removeEntry(path)}
|
|
356
|
+
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded text-faint opacity-0 transition-all hover:bg-danger/10 hover:text-danger focus-visible:opacity-100 group-hover/row:opacity-100"
|
|
353
357
|
aria-label={`Delete ${path}`}
|
|
358
|
+
title={`Delete ${path}`}
|
|
354
359
|
>
|
|
355
360
|
<Trash2 size={10} />
|
|
356
|
-
</
|
|
357
|
-
</
|
|
358
|
-
{!closed &&
|
|
361
|
+
</button>
|
|
362
|
+
</div>
|
|
363
|
+
{!closed && (
|
|
364
|
+
<div className="relative">
|
|
365
|
+
<span
|
|
366
|
+
aria-hidden
|
|
367
|
+
className="pointer-events-none absolute bottom-0.5 top-0 w-px bg-line/60"
|
|
368
|
+
style={{ left: 11 + depth * 14 }}
|
|
369
|
+
/>
|
|
370
|
+
{node.children.map((child) => renderNode(child, depth + 1))}
|
|
371
|
+
</div>
|
|
372
|
+
)}
|
|
359
373
|
</div>
|
|
360
374
|
)
|
|
361
375
|
}
|
|
@@ -365,14 +379,24 @@ export function WorkspacePanel() {
|
|
|
365
379
|
type="button"
|
|
366
380
|
onClick={() => setActiveFile(path)}
|
|
367
381
|
className={cn(
|
|
368
|
-
'flex h-7 w-full items-center gap-1.5 rounded-
|
|
369
|
-
activeFile === path
|
|
382
|
+
'group/row relative flex h-7 w-full items-center gap-1.5 rounded-lg pr-1.5 text-left font-mono text-[10.5px] transition-colors',
|
|
383
|
+
activeFile === path
|
|
384
|
+
? 'bg-accent-soft/80 font-medium text-accent'
|
|
385
|
+
: 'text-muted hover:bg-raised/80 hover:text-ink',
|
|
370
386
|
)}
|
|
371
|
-
style={{ paddingLeft:
|
|
387
|
+
style={{ paddingLeft: 22 + depth * 14 }}
|
|
372
388
|
title={path}
|
|
373
389
|
>
|
|
374
|
-
|
|
390
|
+
{activeFile === path && (
|
|
391
|
+
<span aria-hidden className="absolute left-0 top-1 bottom-1 w-[2px] rounded-full bg-accent" />
|
|
392
|
+
)}
|
|
393
|
+
<FileIcon path={path} size={12} className={cn(activeFile === path && 'text-accent')} />
|
|
375
394
|
<span className="min-w-0 flex-1 truncate">{node.entry.name}</span>
|
|
395
|
+
{node.entry.size > 0 && (
|
|
396
|
+
<span className="shrink-0 font-mono text-[8.5px] tabular-nums text-faint opacity-0 transition-opacity group-hover/row:opacity-100">
|
|
397
|
+
{formatBytes(node.entry.size)}
|
|
398
|
+
</span>
|
|
399
|
+
)}
|
|
376
400
|
</button>
|
|
377
401
|
)
|
|
378
402
|
}
|
|
@@ -515,6 +539,20 @@ export function WorkspacePanel() {
|
|
|
515
539
|
aria-label="Find a file"
|
|
516
540
|
/>
|
|
517
541
|
</div>
|
|
542
|
+
<div className="flex items-center justify-between gap-2 px-1 pt-1.5 text-[8.5px] text-faint">
|
|
543
|
+
<span className="truncate">
|
|
544
|
+
{filtered ? `${filtered.length} matches` : `${fileCount} files · ${dirCount} folders`}
|
|
545
|
+
</span>
|
|
546
|
+
{!filtered && dirCount > 0 && (
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
onClick={() => setCollapsed(collapsed.size > 0 ? new Set() : new Set(dirPaths))}
|
|
550
|
+
className="shrink-0 font-medium hover:text-ink"
|
|
551
|
+
>
|
|
552
|
+
{collapsed.size > 0 ? 'Expand all' : 'Collapse all'}
|
|
553
|
+
</button>
|
|
554
|
+
)}
|
|
555
|
+
</div>
|
|
518
556
|
{editor && (
|
|
519
557
|
<div className="mt-1.5 flex items-center gap-1">
|
|
520
558
|
<input
|
|
@@ -549,24 +587,34 @@ export function WorkspacePanel() {
|
|
|
549
587
|
<Spinner size={11} /> Reading folder…
|
|
550
588
|
</p>
|
|
551
589
|
)}
|
|
552
|
-
{filtered
|
|
553
|
-
|
|
590
|
+
{filtered ? (
|
|
591
|
+
<>
|
|
592
|
+
{filtered.map((entry) => (
|
|
554
593
|
<button
|
|
555
594
|
key={entry.path}
|
|
556
595
|
type="button"
|
|
557
596
|
onClick={() => entry.type === 'file' && setActiveFile(entry.path)}
|
|
558
597
|
className={cn(
|
|
559
|
-
'flex h-7 w-full items-center gap-1.5 rounded-
|
|
598
|
+
'group/row relative flex h-7 w-full items-center gap-1.5 rounded-lg px-2 text-left font-mono text-[10.5px] transition-colors',
|
|
560
599
|
activeFile === entry.path
|
|
561
|
-
? 'bg-accent-soft font-medium text-accent'
|
|
562
|
-
: 'text-muted hover:bg-raised hover:text-ink',
|
|
600
|
+
? 'bg-accent-soft/80 font-medium text-accent'
|
|
601
|
+
: 'text-muted hover:bg-raised/80 hover:text-ink',
|
|
563
602
|
)}
|
|
564
603
|
>
|
|
565
|
-
{
|
|
604
|
+
{activeFile === entry.path && (
|
|
605
|
+
<span aria-hidden className="absolute left-0 top-1 bottom-1 w-[2px] rounded-full bg-accent" />
|
|
606
|
+
)}
|
|
607
|
+
<FileIcon path={entry.path} type={entry.type === 'dir' ? 'dir' : 'file'} size={12} />
|
|
566
608
|
<span className="truncate">{entry.path}</span>
|
|
567
609
|
</button>
|
|
568
|
-
))
|
|
569
|
-
|
|
610
|
+
))}
|
|
611
|
+
{filtered.length === 0 && (
|
|
612
|
+
<p className="px-2 py-4 text-center text-[10.5px] text-faint">No matches in this folder.</p>
|
|
613
|
+
)}
|
|
614
|
+
</>
|
|
615
|
+
) : (
|
|
616
|
+
tree.map((node) => renderNode(node, 0))
|
|
617
|
+
)}
|
|
570
618
|
{!loadingTree && !treeError && entries.length === 0 && (
|
|
571
619
|
<p className="px-2 py-4 text-center text-[10.5px] text-faint">This folder is empty.</p>
|
|
572
620
|
)}
|
package/src/core/kernel.ts
CHANGED
|
@@ -17,6 +17,7 @@ import * as toolGrep from './plugins/tools/grep'
|
|
|
17
17
|
import * as toolGit from './plugins/tools/git'
|
|
18
18
|
import * as toolWeb from './plugins/tools/web'
|
|
19
19
|
import * as toolSubagent from './plugins/tools/subagent'
|
|
20
|
+
import * as toolSkill from './plugins/tools/skill'
|
|
20
21
|
|
|
21
22
|
export function createAppKernel(): Context {
|
|
22
23
|
const ctx = new Context()
|
|
@@ -40,6 +41,7 @@ export function createAppKernel(): Context {
|
|
|
40
41
|
ctx.plugin(toolGit)
|
|
41
42
|
ctx.plugin(toolWeb)
|
|
42
43
|
ctx.plugin(toolSubagent)
|
|
44
|
+
ctx.plugin(toolSkill)
|
|
43
45
|
|
|
44
46
|
return ctx
|
|
45
47
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Context } from 'cordis'
|
|
2
|
+
import { executeDaemonTool } from '../../../lib/daemon'
|
|
3
|
+
import type { Skill } from '../../../types'
|
|
4
|
+
import type { ToolResult } from '../../types'
|
|
5
|
+
|
|
6
|
+
function findSkill(skills: Skill[], name: string): Skill | undefined {
|
|
7
|
+
const clean = name.trim().toLowerCase()
|
|
8
|
+
return skills.find((skill) => skill.id.toLowerCase() === clean || skill.name.toLowerCase() === clean)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function text(args: Record<string, unknown>, key: string): string {
|
|
12
|
+
return typeof args[key] === 'string' ? String(args[key]).trim() : ''
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const name = 'tool-skill'
|
|
16
|
+
export const inject = ['tools']
|
|
17
|
+
|
|
18
|
+
export function apply(ctx: Context) {
|
|
19
|
+
const unregister = ctx.tools.register({
|
|
20
|
+
name: 'skill',
|
|
21
|
+
description:
|
|
22
|
+
'Work with installed skills. Use action "list" to see them, "read" to load a skill’s SKILL.md, and "run" to execute a command inside the skill folder.',
|
|
23
|
+
parameters: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
action: { type: 'string', enum: ['list', 'read', 'run'], description: 'What to do.' },
|
|
27
|
+
name: { type: 'string', description: 'Skill id or name. Required for read and run.' },
|
|
28
|
+
command: { type: 'string', description: 'Shell command to run inside the skill folder. Required for run.' },
|
|
29
|
+
},
|
|
30
|
+
required: ['action'],
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
allowedInMode: () => true,
|
|
34
|
+
async execute(args, exec): Promise<ToolResult> {
|
|
35
|
+
const skills = exec.settings.skills.filter((skill) => skill.enabled)
|
|
36
|
+
const action = text(args, 'action') || 'list'
|
|
37
|
+
|
|
38
|
+
if (action === 'list') {
|
|
39
|
+
if (skills.length === 0) return { output: 'No skills are installed.', isError: false }
|
|
40
|
+
return {
|
|
41
|
+
output: skills
|
|
42
|
+
.map((skill) => `${skill.name} (${skill.id})${skill.package ? ` · ${skill.package}` : ''}\n ${skill.description}`)
|
|
43
|
+
.join('\n'),
|
|
44
|
+
isError: false,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const target = findSkill(skills, text(args, 'name'))
|
|
49
|
+
if (!target) {
|
|
50
|
+
return {
|
|
51
|
+
output: `No enabled skill matches "${text(args, 'name')}". Call action "list" to see the available skills.`,
|
|
52
|
+
isError: true,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (action === 'read') {
|
|
57
|
+
const files = Object.keys(target.files ?? {})
|
|
58
|
+
return {
|
|
59
|
+
output: [
|
|
60
|
+
`# ${target.name}`,
|
|
61
|
+
target.description,
|
|
62
|
+
target.package ? `Package: ${target.package}` : '',
|
|
63
|
+
files.length > 0 ? `Files:\n${files.map((file) => `- ${file}`).join('\n')}` : '',
|
|
64
|
+
'---',
|
|
65
|
+
target.instructions.trim() || 'This skill has no instructions.',
|
|
66
|
+
]
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join('\n\n'),
|
|
69
|
+
isError: false,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (action === 'run') {
|
|
74
|
+
if (exec.mode === 'plan') {
|
|
75
|
+
return { output: 'Plan mode is read-only: skill scripts may change the machine, so they cannot run here.', isError: true }
|
|
76
|
+
}
|
|
77
|
+
const command = text(args, 'command')
|
|
78
|
+
if (!command) return { output: 'Pass the command to run inside the skill folder.', isError: true }
|
|
79
|
+
if (!target.files || Object.keys(target.files).length === 0) {
|
|
80
|
+
return { output: `"${target.name}" has no files to run. Install it from a folder or npm.`, isError: true }
|
|
81
|
+
}
|
|
82
|
+
const result = (await executeDaemonTool(
|
|
83
|
+
'skill_run',
|
|
84
|
+
{ name: target.id, files: target.files, command },
|
|
85
|
+
exec.mode,
|
|
86
|
+
exec.signal,
|
|
87
|
+
)).result as { stdout?: string; stderr?: string; code?: number }
|
|
88
|
+
const code = typeof result.code === 'number' ? result.code : 1
|
|
89
|
+
const body = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
|
|
90
|
+
return {
|
|
91
|
+
output: body || `The skill command exited with code ${code} and printed nothing.`,
|
|
92
|
+
isError: code !== 0,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { output: `Unknown skill action "${action}". Use list, read or run.`, isError: true }
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return () => unregister()
|
|
101
|
+
}
|
|
@@ -4,17 +4,16 @@ import { activeModelId, useSettings } from '../../store/settings'
|
|
|
4
4
|
import { createId } from '../../store/chats'
|
|
5
5
|
import { findModel } from '../../store/models'
|
|
6
6
|
import { useRuntime } from '../../store/ui'
|
|
7
|
+
import { useWorkspace } from '../../store/workspace'
|
|
7
8
|
import { getProviderName } from '../../data/providers'
|
|
8
9
|
import { describeRequestError } from '../../lib/sse'
|
|
10
|
+
import { MAX_PROVIDER_RETRIES, isRetryableProviderError, retryDelayMs } from '../../lib/retry'
|
|
9
11
|
import type { AdapterResult } from '../../agent/adapters/shared'
|
|
10
12
|
import { createTurnQueue, lastUserMessageId } from '../../agent/queue'
|
|
11
13
|
import { resolveToolSetting } from '../mcp-tools'
|
|
12
14
|
import type { StepContext, StepResult, TurnResult } from '../types'
|
|
13
15
|
|
|
14
|
-
const
|
|
15
|
-
const MAX_RETRIES = 2
|
|
16
|
-
/** Provider hiccups worth another try: overload, rate limits, 5xx and dropped connections. */
|
|
17
|
-
const RETRYABLE = /\b(429|500|502|503|504|529)\b|rate.?limit|overloaded|temporar|timed out|could not be reached|lost the connection|failed to fetch|networkerror/i
|
|
16
|
+
const MAX_RETRIES = MAX_PROVIDER_RETRIES
|
|
18
17
|
|
|
19
18
|
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
20
19
|
return new Promise((resolve) => {
|
|
@@ -112,7 +111,8 @@ export class AgentLoopService extends Service {
|
|
|
112
111
|
let turnError: string | undefined
|
|
113
112
|
|
|
114
113
|
try {
|
|
115
|
-
|
|
114
|
+
// No round cap: the turn ends when the model stops calling tools or the user stops it.
|
|
115
|
+
rounds: for (let round = 0; ; round += 1) {
|
|
116
116
|
totalRounds = round + 1
|
|
117
117
|
if (controller.signal.aborted) break
|
|
118
118
|
let current = this.ctx.sessions.current(sessionId)
|
|
@@ -188,7 +188,12 @@ export class AgentLoopService extends Service {
|
|
|
188
188
|
settings,
|
|
189
189
|
model: findModel(settings.provider, activeModelId(settings)),
|
|
190
190
|
history,
|
|
191
|
-
system: this.ctx.systemPrompt.assemble(settings, mode
|
|
191
|
+
system: this.ctx.systemPrompt.assemble(settings, mode, {
|
|
192
|
+
sessionPrompt: current.systemPrompt,
|
|
193
|
+
workspacePrompt: current.workspace
|
|
194
|
+
? settings.workspacePrompts[current.workspace]
|
|
195
|
+
: settings.workspacePrompts[useWorkspace.getState().root ?? ''],
|
|
196
|
+
}),
|
|
192
197
|
mode,
|
|
193
198
|
signal: controller.signal,
|
|
194
199
|
handlers: {
|
|
@@ -215,23 +220,34 @@ export class AgentLoopService extends Service {
|
|
|
215
220
|
attempt < MAX_RETRIES &&
|
|
216
221
|
!streamedContent &&
|
|
217
222
|
!streamedReasoning &&
|
|
218
|
-
|
|
223
|
+
isRetryableProviderError(error instanceof Error ? error.message : '')
|
|
219
224
|
if (retryable) {
|
|
220
225
|
attempt += 1
|
|
226
|
+
const reason = (error instanceof Error ? error.message : 'provider error').replace(/\s+/g, ' ').slice(0, 240)
|
|
221
227
|
window.dispatchEvent(
|
|
222
228
|
new CustomEvent('maxcode:notice', {
|
|
223
229
|
detail: {
|
|
224
|
-
message: `${providerName}
|
|
230
|
+
message: `${providerName}: ${reason} — retrying ${attempt}/${MAX_RETRIES}…`,
|
|
225
231
|
tone: 'info',
|
|
226
232
|
},
|
|
227
233
|
}),
|
|
228
234
|
)
|
|
229
|
-
await sleep(
|
|
235
|
+
await sleep(retryDelayMs(attempt), controller.signal)
|
|
230
236
|
if (controller.signal.aborted) continue
|
|
231
237
|
continue
|
|
232
238
|
}
|
|
233
239
|
if (flushTimer !== undefined) window.clearTimeout(flushTimer)
|
|
234
240
|
turnError = stopped ? undefined : describeRequestError(error, providerName)
|
|
241
|
+
if (turnError) {
|
|
242
|
+
window.dispatchEvent(
|
|
243
|
+
new CustomEvent('maxcode:notice', {
|
|
244
|
+
detail: {
|
|
245
|
+
message: `${providerName} failed after ${attempt + 1} attempt${attempt === 0 ? '' : 's'}: ${turnError}`,
|
|
246
|
+
tone: 'error',
|
|
247
|
+
},
|
|
248
|
+
}),
|
|
249
|
+
)
|
|
250
|
+
}
|
|
235
251
|
this.ctx.sessions.updateMessage(sessionId, assistantId, {
|
|
236
252
|
content: streamedContent,
|
|
237
253
|
reasoning: streamedReasoning,
|
|
@@ -250,7 +266,7 @@ export class AgentLoopService extends Service {
|
|
|
250
266
|
reasoning: result.reasoning,
|
|
251
267
|
thinkingBlocks: result.thinkingBlocks,
|
|
252
268
|
toolCalls: result.toolCalls,
|
|
253
|
-
usage: usage.input || usage.output ? usage : result.usage,
|
|
269
|
+
usage: usage.input || usage.output || usage.cost ? usage : result.usage,
|
|
254
270
|
durationMs: Date.now() - startedAt,
|
|
255
271
|
stopped: controller.signal.aborted || undefined,
|
|
256
272
|
})
|
|
@@ -384,24 +400,6 @@ export class AgentLoopService extends Service {
|
|
|
384
400
|
break
|
|
385
401
|
}
|
|
386
402
|
}
|
|
387
|
-
|
|
388
|
-
const finalSession = this.ctx.sessions.current(sessionId)
|
|
389
|
-
if (!controller.signal.aborted && finalSession) {
|
|
390
|
-
const last = finalSession.messages[finalSession.messages.length - 1]
|
|
391
|
-
if (last?.role === 'tool') {
|
|
392
|
-
const message: AssistantMessage = {
|
|
393
|
-
id: createId('msg'),
|
|
394
|
-
role: 'assistant',
|
|
395
|
-
content: '',
|
|
396
|
-
reasoning: '',
|
|
397
|
-
toolCalls: [],
|
|
398
|
-
round: MAX_ROUNDS,
|
|
399
|
-
createdAt: Date.now(),
|
|
400
|
-
error: `Stopped after ${MAX_ROUNDS} tool rounds. Send another message to continue.`,
|
|
401
|
-
}
|
|
402
|
-
this.ctx.sessions.addMessage(sessionId, message)
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
403
|
} finally {
|
|
406
404
|
this.controllers.delete(sessionId)
|
|
407
405
|
useRuntime.getState().setStreaming(sessionId, false)
|
|
@@ -43,28 +43,30 @@ export class SystemPromptService extends Service {
|
|
|
43
43
|
return () => this.customSections.delete(name)
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
assemble(
|
|
46
|
+
assemble(
|
|
47
|
+
settings: AppSettings,
|
|
48
|
+
mode: AgentMode = settings.mode,
|
|
49
|
+
overrides: { sessionPrompt?: string; workspacePrompt?: string } = {},
|
|
50
|
+
): string {
|
|
47
51
|
const sections: PromptSection[] = []
|
|
48
52
|
|
|
49
|
-
// 1. Base instruction
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
// 1. Base instruction: session override, then workspace override, then the shared prompt.
|
|
54
|
+
const base = overrides.sessionPrompt?.trim() || overrides.workspacePrompt?.trim() || settings.systemPrompt?.trim()
|
|
55
|
+
if (base) {
|
|
56
|
+
sections.push({ name: 'base', priority: 10, content: base })
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
// 2.
|
|
55
|
-
const skills = settings.skills.filter((skill) => skill.enabled)
|
|
59
|
+
// 2. Skills are discovered, not pasted: the model loads one through the skill tool.
|
|
60
|
+
const skills = settings.tools.skill?.enabled === false ? [] : settings.skills.filter((skill) => skill.enabled)
|
|
56
61
|
if (skills.length > 0) {
|
|
57
62
|
sections.push({
|
|
58
63
|
name: 'skills',
|
|
59
64
|
priority: 20,
|
|
60
65
|
content: [
|
|
61
|
-
'##
|
|
62
|
-
'
|
|
63
|
-
...skills.map(
|
|
64
|
-
|
|
65
|
-
`<skill name="${skill.name}">\nPurpose: ${skill.description}\n${skill.instructions.trim()}\n</skill>`,
|
|
66
|
-
),
|
|
67
|
-
].join('\n\n'),
|
|
66
|
+
'## Available skills',
|
|
67
|
+
'Call the `skill` tool to load the full instructions (and run scripts from the skill folder) when a task matches one of these. Do not guess a skill’s contents.',
|
|
68
|
+
...skills.map((skill) => `- ${skill.name} (${skill.id}): ${skill.description}`),
|
|
69
|
+
].join('\n'),
|
|
68
70
|
})
|
|
69
71
|
}
|
|
70
72
|
|
package/src/lib/daemon.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ProviderId,
|
|
13
13
|
ProviderStatus,
|
|
14
14
|
SecretsSummary,
|
|
15
|
+
Skill,
|
|
15
16
|
WorkspaceFile,
|
|
16
17
|
WorkspaceInfo,
|
|
17
18
|
} from '../types'
|
|
@@ -263,6 +264,10 @@ export function setDeployCredentials(provider: DeployProviderId, fields: Record<
|
|
|
263
264
|
})
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
export function installSkillFromNpmRequest(packageName: string) {
|
|
268
|
+
return api<{ skill: Skill }>('/api/skills/install', { method: 'POST', body: JSON.stringify({ package: packageName }) })
|
|
269
|
+
}
|
|
270
|
+
|
|
266
271
|
export function resetScope(scope: 'history' | 'secrets' | 'settings' | 'all'): Promise<DaemonSnapshot> {
|
|
267
272
|
return api('/api/reset', jsonBody({ scope }))
|
|
268
273
|
}
|
|
@@ -452,6 +457,20 @@ export async function requestLLM(
|
|
|
452
457
|
return response
|
|
453
458
|
}
|
|
454
459
|
|
|
460
|
+
export interface DeployInspection {
|
|
461
|
+
path: string
|
|
462
|
+
name: string
|
|
463
|
+
hasPackageJson: boolean
|
|
464
|
+
framework: string
|
|
465
|
+
buildCommand: string
|
|
466
|
+
outputDir: string
|
|
467
|
+
existingOutputs: string[]
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function inspectDeploy(path: string): Promise<DeployInspection> {
|
|
471
|
+
return api('/api/deploy/inspect', { method: 'POST', body: JSON.stringify({ path }) })
|
|
472
|
+
}
|
|
473
|
+
|
|
455
474
|
export async function* streamDeploy(
|
|
456
475
|
request: DeployRequest,
|
|
457
476
|
signal?: AbortSignal,
|
package/src/lib/permissions.ts
CHANGED
|
@@ -21,7 +21,7 @@ export const PERMISSION_MODES: Array<{ value: PermissionMode; label: string; des
|
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
const WRITE_TOOLS: BuiltinToolName[] = ['write_file', 'edit_file', 'rewrite_file']
|
|
24
|
-
const GUARDED_TOOLS: BuiltinToolName[] = ['shell', 'git', 'subagent']
|
|
24
|
+
const GUARDED_TOOLS: BuiltinToolName[] = ['shell', 'git', 'subagent', 'skill']
|
|
25
25
|
|
|
26
26
|
export function permissionMode(tools: AppSettings['tools']): PermissionMode {
|
|
27
27
|
const writesAuto = WRITE_TOOLS.every((tool) => tools[tool]?.approval === 'auto')
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { MAX_PROVIDER_RETRIES, isRetryableProviderError, retryDelayMs } from './retry.ts'
|
|
4
|
+
|
|
5
|
+
test('provider hiccups are retried, auth and validation errors are not', () => {
|
|
6
|
+
assert.equal(isRetryableProviderError('429 Too Many Requests'), true)
|
|
7
|
+
assert.equal(isRetryableProviderError('503 Service Unavailable'), true)
|
|
8
|
+
assert.equal(isRetryableProviderError('socket hang up: ECONNRESET'), true)
|
|
9
|
+
assert.equal(isRetryableProviderError('401 Unauthorized: invalid api key'), false)
|
|
10
|
+
assert.equal(isRetryableProviderError('400 invalid request: unknown field'), false)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('five retries with a capped backoff', () => {
|
|
14
|
+
assert.equal(MAX_PROVIDER_RETRIES, 5)
|
|
15
|
+
assert.equal(retryDelayMs(1), 1200)
|
|
16
|
+
assert.equal(retryDelayMs(5), 8000)
|
|
17
|
+
})
|
package/src/lib/retry.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Automatic retries for provider hiccups: overload, rate limits, 5xx and dropped connections. */
|
|
2
|
+
export const MAX_PROVIDER_RETRIES = 5
|
|
3
|
+
|
|
4
|
+
const RETRYABLE =
|
|
5
|
+
/\b(408|409|425|429|500|502|503|504|522|524|529)\b|rate.?limit|overloaded|temporar|timed out|could not be reached|lost the connection|failed to fetch|networkerror|econnreset|etimedout|enotfound|eai_again/i
|
|
6
|
+
|
|
7
|
+
export function isRetryableProviderError(message: string): boolean {
|
|
8
|
+
return RETRYABLE.test(message)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function retryDelayMs(attempt: number): number {
|
|
12
|
+
return Math.min(1200 * attempt * attempt, 8000)
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { parseSkillMarkdown } from './skills.ts'
|
|
4
|
+
|
|
5
|
+
test('frontmatter supplies the name and description', () => {
|
|
6
|
+
const skill = parseSkillMarkdown('---\nname: API reviewer\ndescription: "Checks a diff"\n---\n# Ignored\n\nBody text.', 'fallback')
|
|
7
|
+
assert.equal(skill.name, 'API reviewer')
|
|
8
|
+
assert.equal(skill.description, 'Checks a diff')
|
|
9
|
+
assert.equal(skill.instructions, '# Ignored\n\nBody text.')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('a plain SKILL.md falls back to its heading and first paragraph', () => {
|
|
13
|
+
const skill = parseSkillMarkdown('# Deploy helper\n\nRuns the release checklist.\n\n## Steps\n1. build', 'fallback')
|
|
14
|
+
assert.equal(skill.name, 'Deploy helper')
|
|
15
|
+
assert.equal(skill.description, 'Runs the release checklist.')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('an empty fallback markdown still returns a usable skill', () => {
|
|
19
|
+
const skill = parseSkillMarkdown('', 'my-skill')
|
|
20
|
+
assert.equal(skill.name, 'my-skill')
|
|
21
|
+
assert.equal(skill.description, 'Installed from a folder')
|
|
22
|
+
})
|