@bibobonking/opensol 3.1.6 → 3.2.0
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 +9 -7
- package/README.md +24 -20
- package/desktop/README.md +1 -1
- package/dist/assets/index-CyxlAgKM.js +271 -0
- package/dist/assets/index-DFeF90S8.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +3 -6
- package/server/README.md +15 -68
- package/server/daemon.mjs +6 -1
- package/server/lib/deploy.mjs +5 -10
- package/server/lib/shell.mjs +149 -0
- package/server/lib/shell.test.mjs +72 -0
- package/server/lib/tools.mjs +8 -44
- package/src/agent/adapters/anthropic.ts +2 -3
- package/src/agent/adapters/gemini.ts +2 -3
- package/src/agent/adapters/openai-responses.ts +2 -3
- package/src/agent/adapters/openai.ts +2 -3
- package/src/agent/adapters/shared.ts +3 -0
- package/src/components/ChatView.tsx +17 -54
- package/src/components/Composer.tsx +143 -135
- package/src/components/ContextBar.tsx +2 -2
- package/src/components/HomeDashboard.tsx +7 -4
- package/src/components/MessageItem.tsx +33 -1
- package/src/components/ModelDeck.tsx +5 -27
- package/src/components/SettingsModal.tsx +8 -0
- package/src/components/ToolCallCard.tsx +2 -2
- package/src/components/TrajectoryView.tsx +1 -1
- package/src/components/composer/PlusMenu.tsx +117 -0
- package/src/components/composer/Popover.tsx +109 -0
- package/src/components/composer/WorkspacePicker.tsx +102 -0
- package/src/core/plugins/tools/shell.ts +2 -2
- package/src/core/plugins/tools/subagent.ts +18 -8
- package/src/core/services/agent-loop.ts +45 -43
- package/src/core/services/compaction.ts +289 -22
- package/src/core/services/llm.ts +6 -2
- package/src/core/services/subagents.ts +191 -3
- package/src/core/services/system-prompt.ts +35 -8
- package/src/core/services/tools.ts +2 -1
- package/src/core/types.ts +5 -0
- package/src/lib/compact-plan.test.mjs +62 -0
- package/src/lib/compact-plan.ts +188 -0
- package/src/lib/subagent-types.ts +54 -0
- package/src/lib/tool-meta.ts +40 -0
- package/src/store/settings.ts +3 -0
- package/src/store/ui.ts +12 -0
- package/src/types.ts +26 -0
- package/shared/plugin-builder.test.mjs +0 -43
- package/shared/plugin-package.d.mts +0 -34
- package/shared/plugin-package.mjs +0 -197
- package/shared/plugin-package.test.mjs +0 -94
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { Check, Globe2, Paperclip, Plug, Plus, Sparkles } from 'lucide-react'
|
|
3
|
+
import { useSettings } from '../../store/settings'
|
|
4
|
+
import { useSkills } from '../../store/skills'
|
|
5
|
+
import { useMcp } from '../../store/mcp'
|
|
6
|
+
import { useUi } from '../../store/ui'
|
|
7
|
+
import { Popover } from './Popover'
|
|
8
|
+
import { cn } from '../../lib/utils'
|
|
9
|
+
|
|
10
|
+
function Row({
|
|
11
|
+
icon: Icon,
|
|
12
|
+
label,
|
|
13
|
+
hint,
|
|
14
|
+
active,
|
|
15
|
+
onClick,
|
|
16
|
+
}: {
|
|
17
|
+
icon: typeof Plus
|
|
18
|
+
label: string
|
|
19
|
+
hint?: string
|
|
20
|
+
active?: boolean
|
|
21
|
+
onClick: () => void
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
onClick={onClick}
|
|
27
|
+
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-[12px] text-ink transition-colors hover:bg-raised"
|
|
28
|
+
>
|
|
29
|
+
<Icon size={14} className="shrink-0 text-faint" />
|
|
30
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
31
|
+
{hint && <span className="shrink-0 text-[10.5px] text-faint">{hint}</span>}
|
|
32
|
+
{active !== undefined && (
|
|
33
|
+
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
|
34
|
+
{active && <Check size={13} className="text-accent" />}
|
|
35
|
+
</span>
|
|
36
|
+
)}
|
|
37
|
+
</button>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The "+" menu: files, web search, mode, skills and MCP. */
|
|
42
|
+
export function PlusMenu({ onAddFiles }: { onAddFiles: () => void }) {
|
|
43
|
+
const settings = useSettings()
|
|
44
|
+
const update = useSettings((state) => state.update)
|
|
45
|
+
const enabledCount = useSkills((state) => state.skills.filter((skill) => skill.enabled).length)
|
|
46
|
+
const skillsLoaded = useSkills((state) => state.loaded)
|
|
47
|
+
const loadSkills = useSkills((state) => state.load)
|
|
48
|
+
const servers = useMcp((state) => state.servers)
|
|
49
|
+
const mcpLoaded = useMcp((state) => state.loaded)
|
|
50
|
+
const loadMcp = useMcp((state) => state.load)
|
|
51
|
+
const openSettings = useUi((state) => state.openSettings)
|
|
52
|
+
const setMcpOpen = useUi((state) => state.setMcpOpen)
|
|
53
|
+
const [open, setOpen] = useState(false)
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!open) return
|
|
57
|
+
if (!skillsLoaded) void loadSkills()
|
|
58
|
+
if (!mcpLoaded) void loadMcp()
|
|
59
|
+
}, [open, skillsLoaded, mcpLoaded, loadSkills, loadMcp])
|
|
60
|
+
|
|
61
|
+
const connected = servers.filter((server) => server.status === 'connected').length
|
|
62
|
+
const close = () => setOpen(false)
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Popover
|
|
66
|
+
open={open}
|
|
67
|
+
onClose={close}
|
|
68
|
+
width={280}
|
|
69
|
+
trigger={
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => setOpen((value) => !value)}
|
|
73
|
+
className={cn('icon-button h-8 w-8', open && 'bg-raised text-ink')}
|
|
74
|
+
aria-label="Add files, skills and tools"
|
|
75
|
+
aria-expanded={open}
|
|
76
|
+
title="Add files, skills and tools"
|
|
77
|
+
>
|
|
78
|
+
<Plus size={16} className={cn('transition-transform duration-200', open && 'rotate-45')} />
|
|
79
|
+
</button>
|
|
80
|
+
}
|
|
81
|
+
>
|
|
82
|
+
<Row
|
|
83
|
+
icon={Paperclip}
|
|
84
|
+
label="Add files or photos"
|
|
85
|
+
onClick={() => {
|
|
86
|
+
close()
|
|
87
|
+
onAddFiles()
|
|
88
|
+
}}
|
|
89
|
+
/>
|
|
90
|
+
<Row
|
|
91
|
+
icon={Globe2}
|
|
92
|
+
label="Web search"
|
|
93
|
+
active={settings.webSearchEnabled}
|
|
94
|
+
onClick={() => update({ webSearchEnabled: !settings.webSearchEnabled })}
|
|
95
|
+
/>
|
|
96
|
+
<Row
|
|
97
|
+
icon={Plug}
|
|
98
|
+
label="MCP servers"
|
|
99
|
+
hint={servers.length ? `${connected}/${servers.length} connected` : 'Add'}
|
|
100
|
+
onClick={() => {
|
|
101
|
+
close()
|
|
102
|
+
setMcpOpen(true)
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
|
|
106
|
+
<Row
|
|
107
|
+
icon={Sparkles}
|
|
108
|
+
label="Skills"
|
|
109
|
+
hint={enabledCount ? `${enabledCount} on` : undefined}
|
|
110
|
+
onClick={() => {
|
|
111
|
+
close()
|
|
112
|
+
openSettings('skills')
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</Popover>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { useClickOutside } from '../../lib/hooks'
|
|
3
|
+
import { cn } from '../../lib/utils'
|
|
4
|
+
|
|
5
|
+
const EDGE = 12
|
|
6
|
+
const GAP = 8
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A trigger plus a floating panel. It opens on the side with room (flipping above/below, and
|
|
10
|
+
* left/right), never grows past `maxHeight` or the viewport, and scrolls inside when longer.
|
|
11
|
+
*/
|
|
12
|
+
export function Popover({
|
|
13
|
+
open,
|
|
14
|
+
onClose,
|
|
15
|
+
trigger,
|
|
16
|
+
children,
|
|
17
|
+
width = 260,
|
|
18
|
+
placement = 'above',
|
|
19
|
+
maxHeight = 260,
|
|
20
|
+
className,
|
|
21
|
+
}: {
|
|
22
|
+
open: boolean
|
|
23
|
+
onClose: () => void
|
|
24
|
+
trigger: ReactNode
|
|
25
|
+
children: ReactNode
|
|
26
|
+
width?: number
|
|
27
|
+
/** Preferred side; flips when the other side has clearly more room. */
|
|
28
|
+
placement?: 'above' | 'below'
|
|
29
|
+
/** Upper bound for the panel height before it scrolls. */
|
|
30
|
+
maxHeight?: number
|
|
31
|
+
className?: string
|
|
32
|
+
}) {
|
|
33
|
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
34
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
35
|
+
const [layout, setLayout] = useState({ side: placement, height: maxHeight, alignRight: false })
|
|
36
|
+
useClickOutside(wrapperRef, onClose, open)
|
|
37
|
+
|
|
38
|
+
useLayoutEffect(() => {
|
|
39
|
+
if (!open) return
|
|
40
|
+
const measure = () => {
|
|
41
|
+
const wrapper = wrapperRef.current
|
|
42
|
+
if (!wrapper) return
|
|
43
|
+
const rect = wrapper.getBoundingClientRect()
|
|
44
|
+
const above = rect.top - EDGE - GAP
|
|
45
|
+
const below = window.innerHeight - rect.bottom - EDGE - GAP
|
|
46
|
+
const natural = Math.min(panelRef.current?.scrollHeight ?? maxHeight, maxHeight)
|
|
47
|
+
const fits = (room: number) => room >= natural
|
|
48
|
+
const preferred = placement === 'above' ? above : below
|
|
49
|
+
const other = placement === 'above' ? below : above
|
|
50
|
+
const side = fits(preferred) || preferred >= other ? placement : placement === 'above' ? 'below' : 'above'
|
|
51
|
+
const room = side === 'above' ? above : below
|
|
52
|
+
setLayout({
|
|
53
|
+
side,
|
|
54
|
+
height: Math.max(96, Math.min(maxHeight, room)),
|
|
55
|
+
alignRight: rect.left + width > window.innerWidth - EDGE,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
measure()
|
|
59
|
+
window.addEventListener('resize', measure)
|
|
60
|
+
return () => window.removeEventListener('resize', measure)
|
|
61
|
+
}, [open, placement, maxHeight, width])
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div ref={wrapperRef} className="relative">
|
|
65
|
+
{trigger}
|
|
66
|
+
{open && (
|
|
67
|
+
<div
|
|
68
|
+
ref={panelRef}
|
|
69
|
+
style={{ width, maxHeight: layout.height }}
|
|
70
|
+
className={cn(
|
|
71
|
+
'surface-lift thin-scroll animate-scalein absolute z-40 max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain rounded-2xl border border-line p-1.5 shadow-float',
|
|
72
|
+
layout.alignRight ? 'right-0' : 'left-0',
|
|
73
|
+
layout.side === 'above' ? 'bottom-[calc(100%+8px)]' : 'top-[calc(100%+6px)]',
|
|
74
|
+
className,
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{children}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function MenuLabel({ children }: { children: ReactNode }) {
|
|
85
|
+
return <p className="px-2 pb-1 pt-1.5 text-[10px] font-medium uppercase tracking-[0.1em] text-faint">{children}</p>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A plain-text toolbar button: no border or fill until it is hovered or its panel is open. */
|
|
89
|
+
export function TextButton({
|
|
90
|
+
children,
|
|
91
|
+
open,
|
|
92
|
+
className,
|
|
93
|
+
...props
|
|
94
|
+
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { open?: boolean }) {
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
aria-expanded={open}
|
|
99
|
+
{...props}
|
|
100
|
+
className={cn(
|
|
101
|
+
'flex h-8 items-center gap-1 rounded-lg px-2 text-[12px] font-medium text-muted transition-colors hover:text-ink disabled:cursor-not-allowed disabled:opacity-45',
|
|
102
|
+
open && 'text-ink',
|
|
103
|
+
className,
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
{children}
|
|
107
|
+
</button>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Check, ChevronDown, FolderOpen, GitBranch } from 'lucide-react'
|
|
3
|
+
import { useUi } from '../../store/ui'
|
|
4
|
+
import { useWorkspace } from '../../store/workspace'
|
|
5
|
+
import { Popover, MenuLabel, TextButton } from './Popover'
|
|
6
|
+
import { cn } from '../../lib/utils'
|
|
7
|
+
|
|
8
|
+
/** The project folder, shown above the composer. Click to switch folders. */
|
|
9
|
+
export function WorkspacePicker() {
|
|
10
|
+
const info = useWorkspace((state) => state.info)
|
|
11
|
+
const git = useWorkspace((state) => state.git)
|
|
12
|
+
const addWorkspace = useWorkspace((state) => state.addWorkspace)
|
|
13
|
+
const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
|
|
14
|
+
const notify = useUi((state) => state.notify)
|
|
15
|
+
const [open, setOpen] = useState(false)
|
|
16
|
+
const [switching, setSwitching] = useState('')
|
|
17
|
+
|
|
18
|
+
const choose = async (path: string) => {
|
|
19
|
+
if (path === info.root) {
|
|
20
|
+
setOpen(false)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
setSwitching(path)
|
|
24
|
+
try {
|
|
25
|
+
await addWorkspace(path)
|
|
26
|
+
setOpen(false)
|
|
27
|
+
} catch (error) {
|
|
28
|
+
notify(error instanceof Error ? error.message : 'Could not open that folder.', 'error')
|
|
29
|
+
} finally {
|
|
30
|
+
setSwitching('')
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const recent = [...info.workspaces].sort((a, b) => b.lastOpened - a.lastOpened)
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Popover
|
|
38
|
+
open={open}
|
|
39
|
+
onClose={() => setOpen(false)}
|
|
40
|
+
placement="below"
|
|
41
|
+
width={280}
|
|
42
|
+
maxHeight={260}
|
|
43
|
+
trigger={
|
|
44
|
+
<TextButton
|
|
45
|
+
open={open}
|
|
46
|
+
onClick={() => setOpen((value) => !value)}
|
|
47
|
+
title={info.available ? info.root : 'Choose a project folder'}
|
|
48
|
+
className="max-w-[240px]"
|
|
49
|
+
>
|
|
50
|
+
<FolderOpen size={13} className="shrink-0 text-faint" />
|
|
51
|
+
<span className="truncate">{info.available ? info.name : 'Select folder'}</span>
|
|
52
|
+
{info.available && git.available && git.branch && (
|
|
53
|
+
<span className="flex shrink-0 items-center gap-0.5 text-faint">
|
|
54
|
+
<GitBranch size={11} />
|
|
55
|
+
{git.branch}
|
|
56
|
+
</span>
|
|
57
|
+
)}
|
|
58
|
+
<ChevronDown size={11} className={cn('shrink-0 text-faint transition-transform', open && 'rotate-180')} />
|
|
59
|
+
</TextButton>
|
|
60
|
+
}
|
|
61
|
+
>
|
|
62
|
+
<MenuLabel>Folders</MenuLabel>
|
|
63
|
+
<div>
|
|
64
|
+
{recent.length === 0 && <p className="px-2 pb-2 text-[11.5px] text-faint">No folders yet.</p>}
|
|
65
|
+
{recent.map((workspace) => (
|
|
66
|
+
<button
|
|
67
|
+
key={workspace.path}
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={() => void choose(workspace.path)}
|
|
70
|
+
disabled={Boolean(switching)}
|
|
71
|
+
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-raised disabled:opacity-60"
|
|
72
|
+
title={workspace.path}
|
|
73
|
+
>
|
|
74
|
+
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
|
75
|
+
{workspace.path === info.root ? <Check size={12} className="text-accent" /> : <FolderOpen size={12} className="text-faint" />}
|
|
76
|
+
</span>
|
|
77
|
+
<span className="min-w-0 flex-1">
|
|
78
|
+
<span className="block truncate text-[12px] font-medium text-ink">{workspace.name}</span>
|
|
79
|
+
<span className="block truncate text-[10px] text-faint">{workspace.path}</span>
|
|
80
|
+
</span>
|
|
81
|
+
{switching === workspace.path && (
|
|
82
|
+
<span className="h-3 w-3 shrink-0 animate-spin rounded-full border border-current border-t-transparent text-faint" />
|
|
83
|
+
)}
|
|
84
|
+
</button>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
<div className="mt-1 border-t border-line pt-1">
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={() => {
|
|
91
|
+
setOpen(false)
|
|
92
|
+
setWorkspaceOpen(true)
|
|
93
|
+
}}
|
|
94
|
+
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] text-muted transition-colors hover:bg-raised hover:text-ink"
|
|
95
|
+
>
|
|
96
|
+
<FolderOpen size={12} className="text-faint" />
|
|
97
|
+
Open another folder…
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</Popover>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -55,11 +55,11 @@ export function apply(ctx: Context) {
|
|
|
55
55
|
return ctx.tools.register({
|
|
56
56
|
name: 'shell',
|
|
57
57
|
description:
|
|
58
|
-
'Run a
|
|
58
|
+
'Run a command in the user\'s default shell (bash/zsh on Linux and macOS, PowerShell on Windows; see the Environment section) from the workspace root. The working directory resets between calls. Prefer dedicated file and git tools for edits. In Plan mode only read-only commands are accepted.',
|
|
59
59
|
parameters: {
|
|
60
60
|
type: 'object',
|
|
61
61
|
properties: {
|
|
62
|
-
command: { type: 'string', description: 'The
|
|
62
|
+
command: { type: 'string', description: 'The command to run, written for the shell named in the Environment section.' },
|
|
63
63
|
timeoutMs: { type: 'number', description: 'Timeout in milliseconds (max 600000).' },
|
|
64
64
|
},
|
|
65
65
|
required: ['command'],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Context } from 'cordis'
|
|
2
2
|
import type { ToolResult } from '../../types'
|
|
3
|
+
import { SUBAGENT_TYPES, subagentType } from '../../../lib/subagent-types'
|
|
3
4
|
|
|
4
5
|
const MAX_TOOL_OUTPUT = 28_000
|
|
5
6
|
|
|
@@ -12,25 +13,34 @@ export const name = 'tool-subagent'
|
|
|
12
13
|
export const inject = ['tools', 'subagents']
|
|
13
14
|
|
|
14
15
|
export function apply(ctx: Context) {
|
|
16
|
+
const kinds = Object.entries(SUBAGENT_TYPES)
|
|
17
|
+
.map(([id, spec]) => `"${id}" (${spec.summary})`)
|
|
18
|
+
.join('; ')
|
|
15
19
|
return ctx.tools.register({
|
|
16
20
|
name: 'subagent',
|
|
17
|
-
description:
|
|
18
|
-
'Delegate a focused investigation to a read-only subagent. Use it to explore a large area, trace a bug or verify assumptions without changing files. The subagent returns a concise report.',
|
|
21
|
+
description: `Delegate a focused, read-only investigation to a subagent that works in its own context and returns a concise report. Types: ${kinds}. Give it a complete, self-contained task (it cannot see this conversation) and any file paths or constraints as context. To investigate independent questions in parallel, call this tool several times in the same message.`,
|
|
19
22
|
parameters: {
|
|
20
23
|
type: 'object',
|
|
21
24
|
properties: {
|
|
22
|
-
task: { type: 'string', description: 'The specific question or investigation
|
|
23
|
-
context: { type: 'string', description: 'Optional context
|
|
25
|
+
task: { type: 'string', description: 'The specific question or investigation. Be precise about what the report must contain.' },
|
|
26
|
+
context: { type: 'string', description: 'Optional context: files to start from, what is already known, constraints.' },
|
|
27
|
+
type: { type: 'string', enum: Object.keys(SUBAGENT_TYPES), description: 'explore (default), research or review.' },
|
|
24
28
|
},
|
|
25
29
|
required: ['task'],
|
|
26
30
|
additionalProperties: false,
|
|
27
31
|
},
|
|
28
32
|
allowedInMode: () => true,
|
|
29
33
|
async execute(args, exec): Promise<ToolResult> {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
const report = await ctx.subagents.run({
|
|
35
|
+
task: typeof args.task === 'string' ? args.task : '',
|
|
36
|
+
context: typeof args.context === 'string' ? args.context : '',
|
|
37
|
+
type: subagentType(args.type),
|
|
38
|
+
signal: exec.signal,
|
|
39
|
+
sessionId: exec.sessionId,
|
|
40
|
+
onProgress: exec.onProgress,
|
|
41
|
+
})
|
|
42
|
+
const footer = `\n\n— subagent: ${report.toolCalls} tool call${report.toolCalls === 1 ? '' : 's'}, ${report.rounds} round${report.rounds === 1 ? '' : 's'}${report.capped ? ', stopped at its round budget' : ''}`
|
|
43
|
+
return { output: truncate(report.output) + footer, isError: false }
|
|
34
44
|
},
|
|
35
45
|
})
|
|
36
46
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Service, type Context } from 'cordis'
|
|
2
|
-
import type { AssistantMessage, ToolMessage } from '../../types'
|
|
2
|
+
import type { AssistantMessage, ToolCall, ToolMessage } from '../../types'
|
|
3
3
|
import { activeModelId, useSettings } from '../../store/settings'
|
|
4
4
|
import { createId } from '../../store/chats'
|
|
5
5
|
import { findModel } from '../../store/models'
|
|
@@ -32,11 +32,6 @@ function isAbortError(error: unknown) {
|
|
|
32
32
|
)
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function truncate(value: string, limit = 40_000) {
|
|
36
|
-
if (value.length <= limit) return value
|
|
37
|
-
return `${value.slice(0, limit)}\n… truncated (${value.length - limit} more characters)`
|
|
38
|
-
}
|
|
39
|
-
|
|
40
35
|
export class AgentLoopService extends Service {
|
|
41
36
|
static provide = 'agentLoop'
|
|
42
37
|
static inject = ['sessions', 'systemPrompt', 'tools', 'llm', 'approval', 'compaction', 'subagents']
|
|
@@ -116,13 +111,14 @@ export class AgentLoopService extends Service {
|
|
|
116
111
|
let current = this.ctx.sessions.current(sessionId)
|
|
117
112
|
if (!current) break
|
|
118
113
|
|
|
119
|
-
// Auto-compaction
|
|
120
|
-
// A prompt queued behind this turn would be dropped by the rewrite, so wait
|
|
121
|
-
|
|
122
|
-
|
|
114
|
+
// Auto-compaction: before the turn starts and again between tool rounds, since one long turn can
|
|
115
|
+
// fill the window. A prompt queued behind this turn would be dropped by the rewrite, so wait
|
|
116
|
+
// until this turn is the last one.
|
|
117
|
+
if (lastUserMessageId(current.messages) === userMessageId) {
|
|
118
|
+
const compacted = await this.ctx.compaction.checkAutoCompact(sessionId, { midTurn: round > 0 })
|
|
123
119
|
if (compacted) {
|
|
124
|
-
//
|
|
125
|
-
//
|
|
120
|
+
// The transcript was rewritten: continue from its newest user message (the kept prompt,
|
|
121
|
+
// or the handoff note when compaction happened mid-turn).
|
|
126
122
|
const refreshed = this.ctx.sessions.current(sessionId)
|
|
127
123
|
if (!refreshed) break
|
|
128
124
|
const handoffId = lastUserMessageId(refreshed.messages)
|
|
@@ -275,8 +271,7 @@ export class AgentLoopService extends Service {
|
|
|
275
271
|
|
|
276
272
|
const toolMessages: ToolMessage[] = []
|
|
277
273
|
|
|
278
|
-
|
|
279
|
-
if (controller.signal.aborted) break
|
|
274
|
+
const runToolCall = async (call: ToolCall): Promise<{ message: ToolMessage; stopped: boolean }> => {
|
|
280
275
|
const toolSetting = resolveToolSetting(settings, call.name)
|
|
281
276
|
if (!toolSetting?.enabled) {
|
|
282
277
|
const message: ToolMessage = {
|
|
@@ -289,46 +284,39 @@ export class AgentLoopService extends Service {
|
|
|
289
284
|
createdAt: Date.now(),
|
|
290
285
|
}
|
|
291
286
|
this.ctx.sessions.addMessage(sessionId, message)
|
|
292
|
-
|
|
293
|
-
continue
|
|
287
|
+
return { message, stopped: false }
|
|
294
288
|
}
|
|
295
289
|
|
|
296
290
|
const toolMessageId = createId('tool')
|
|
297
|
-
const awaiting = toolSetting.approval === 'ask'
|
|
298
291
|
const toolMessage: ToolMessage = {
|
|
299
292
|
id: toolMessageId,
|
|
300
293
|
role: 'tool',
|
|
301
294
|
call,
|
|
302
|
-
status:
|
|
295
|
+
status: toolSetting.approval === 'ask' ? 'awaiting' : 'running',
|
|
303
296
|
round,
|
|
304
297
|
createdAt: Date.now(),
|
|
305
298
|
}
|
|
306
299
|
this.ctx.sessions.addMessage(sessionId, toolMessage)
|
|
307
300
|
|
|
301
|
+
// Long tools (subagents) stream a short live log into their card.
|
|
302
|
+
let progressTimer: number | undefined
|
|
303
|
+
let progressText = ''
|
|
304
|
+
const onProgress = (text: string) => {
|
|
305
|
+
progressText = text
|
|
306
|
+
if (progressTimer !== undefined) return
|
|
307
|
+
progressTimer = window.setTimeout(() => {
|
|
308
|
+
progressTimer = undefined
|
|
309
|
+
this.ctx.sessions.updateMessage(sessionId, toolMessageId, { result: progressText })
|
|
310
|
+
}, 250)
|
|
311
|
+
}
|
|
312
|
+
|
|
308
313
|
const toolStarted = Date.now()
|
|
309
314
|
try {
|
|
310
|
-
const output =
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
typeof call.args.task === 'string' ? call.args.task : '',
|
|
316
|
-
typeof call.args.context === 'string' ? call.args.context : '',
|
|
317
|
-
controller.signal,
|
|
318
|
-
sessionId,
|
|
319
|
-
),
|
|
320
|
-
),
|
|
321
|
-
isError: false,
|
|
322
|
-
}
|
|
323
|
-
: await this.ctx.tools.execute(
|
|
324
|
-
call.name,
|
|
325
|
-
call.args,
|
|
326
|
-
settings,
|
|
327
|
-
mode,
|
|
328
|
-
controller.signal,
|
|
329
|
-
call.id,
|
|
330
|
-
userMessageId,
|
|
331
|
-
)
|
|
315
|
+
const output = await this.ctx.tools.execute(call.name, call.args, settings, mode, controller.signal, call.id, userMessageId, {
|
|
316
|
+
sessionId,
|
|
317
|
+
onProgress,
|
|
318
|
+
})
|
|
319
|
+
if (progressTimer !== undefined) window.clearTimeout(progressTimer)
|
|
332
320
|
const finalToolMessage: ToolMessage = {
|
|
333
321
|
...toolMessage,
|
|
334
322
|
status: output.isError ? 'error' : 'done',
|
|
@@ -337,8 +325,9 @@ export class AgentLoopService extends Service {
|
|
|
337
325
|
diff: output.diff,
|
|
338
326
|
}
|
|
339
327
|
this.ctx.sessions.updateMessage(sessionId, toolMessageId, finalToolMessage)
|
|
340
|
-
|
|
328
|
+
return { message: finalToolMessage, stopped: false }
|
|
341
329
|
} catch (error) {
|
|
330
|
+
if (progressTimer !== undefined) window.clearTimeout(progressTimer)
|
|
342
331
|
const stopped = isAbortError(error)
|
|
343
332
|
const finalToolMessage: ToolMessage = {
|
|
344
333
|
...toolMessage,
|
|
@@ -347,9 +336,22 @@ export class AgentLoopService extends Service {
|
|
|
347
336
|
durationMs: Date.now() - toolStarted,
|
|
348
337
|
}
|
|
349
338
|
this.ctx.sessions.updateMessage(sessionId, toolMessageId, finalToolMessage)
|
|
350
|
-
|
|
351
|
-
|
|
339
|
+
return { message: finalToolMessage, stopped }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Calls run in order, except that neighbouring subagent calls run side by side.
|
|
344
|
+
const calls = result.toolCalls
|
|
345
|
+
for (let index = 0; index < calls.length; ) {
|
|
346
|
+
if (controller.signal.aborted) break
|
|
347
|
+
let end = index + 1
|
|
348
|
+
if (calls[index].name === 'subagent') {
|
|
349
|
+
while (end < calls.length && calls[end].name === 'subagent') end += 1
|
|
352
350
|
}
|
|
351
|
+
const outcomes = await Promise.all(calls.slice(index, end).map(runToolCall))
|
|
352
|
+
toolMessages.push(...outcomes.map((outcome) => outcome.message))
|
|
353
|
+
if (outcomes.some((outcome) => outcome.stopped)) break
|
|
354
|
+
index = end
|
|
353
355
|
}
|
|
354
356
|
|
|
355
357
|
const stepResult: StepResult = {
|