@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
|
@@ -1,9 +1,88 @@
|
|
|
1
1
|
import { Service, type Context } from 'cordis'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { findModel } from '../../store/models'
|
|
2
|
+
import type { AssistantMessage, Message, ModelInfo, UserMessage } from '../../types'
|
|
3
|
+
import { createId } from '../../store/chats'
|
|
5
4
|
import { activeModelId, useSettings } from '../../store/settings'
|
|
6
|
-
import {
|
|
5
|
+
import { findModel } from '../../store/models'
|
|
6
|
+
import { useRuntime } from '../../store/ui'
|
|
7
|
+
import { AUTO_COMPACT_THRESHOLD, contextLimit, estimateSessionTokens } from '../../lib/context'
|
|
8
|
+
import { backoffMs, classifyProviderError } from '../../lib/retry'
|
|
9
|
+
import {
|
|
10
|
+
chunkParts,
|
|
11
|
+
clip,
|
|
12
|
+
collectFiles,
|
|
13
|
+
fallbackSummary,
|
|
14
|
+
planCompaction,
|
|
15
|
+
previousSummary,
|
|
16
|
+
recentSteps,
|
|
17
|
+
tokensOf,
|
|
18
|
+
transcriptParts,
|
|
19
|
+
} from '../../lib/compact-plan'
|
|
20
|
+
|
|
21
|
+
const SYSTEM = `You are a session compactor for a coding agent. Produce a dense, structured summary that REPLACES the conversation, so the agent can continue as if it had seen everything.
|
|
22
|
+
|
|
23
|
+
You may receive <previous_summary> (an earlier compaction) and <new_transcript> (what happened after it). Merge them into ONE updated summary: keep what is still true and relevant, drop what was superseded or finished long ago, and never invent facts.
|
|
24
|
+
|
|
25
|
+
Write these sections (omit a section only if it is truly empty):
|
|
26
|
+
|
|
27
|
+
## Goal
|
|
28
|
+
What the user wants overall, with their constraints, preferences and style requirements in their own words.
|
|
29
|
+
|
|
30
|
+
## Done
|
|
31
|
+
Concrete outcomes: what was built or changed (file paths, function names), commands that mattered with their key results, bugs found and how they were fixed, decisions made and why.
|
|
32
|
+
|
|
33
|
+
## State
|
|
34
|
+
The exact current state of the workspace and what has been verified (tests, builds) versus assumed. Anything broken or half-finished.
|
|
35
|
+
|
|
36
|
+
## Next
|
|
37
|
+
The ordered next steps. Quote the user's latest request verbatim if it is not finished.
|
|
38
|
+
|
|
39
|
+
## Facts
|
|
40
|
+
Technical facts the agent would otherwise have to rediscover: versions, commands to run checks, architecture notes, pitfalls, error messages worth remembering.
|
|
41
|
+
|
|
42
|
+
Rules: keep exact paths, identifiers, commands and error text; do not paste large file contents; do not list every file touched (a tracked file list is appended automatically); no preamble or closing remarks; stay under about 1500 words.`
|
|
43
|
+
|
|
44
|
+
const TRANSIENT_ATTEMPTS = 2
|
|
45
|
+
const SUMMARY_PREFIX = '[Summary of the earlier conversation — the original messages were compacted to save context]\n\n'
|
|
46
|
+
|
|
47
|
+
export interface CompactOptions {
|
|
48
|
+
/** Extra instructions for the summary, e.g. "focus on the auth bug". */
|
|
49
|
+
focus?: string
|
|
50
|
+
/** The user asked for it: report problems instead of failing silently. */
|
|
51
|
+
manual?: boolean
|
|
52
|
+
/** Called while the agent is mid-turn: the running request is carried over in a handoff message. */
|
|
53
|
+
midTurn?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function notice(message: string, tone: 'info' | 'success' | 'error') {
|
|
57
|
+
window.dispatchEvent(new CustomEvent('maxcode:notice', { detail: { message, tone } }))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sleep(ms: number): Promise<void> {
|
|
61
|
+
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatK(tokens: number): string {
|
|
65
|
+
return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const compacting = new Set<string>()
|
|
69
|
+
|
|
70
|
+
export function isCompacting(sessionId: string): boolean {
|
|
71
|
+
return compacting.has(sessionId)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Provider-reported prompt size of the newest reply beats our character estimate when it is larger. */
|
|
75
|
+
export function usedTokens(messages: Message[]): number {
|
|
76
|
+
const estimate = estimateSessionTokens(messages) + 2_000
|
|
77
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
78
|
+
const message = messages[index]
|
|
79
|
+
if (message.role === 'assistant' && message.usage?.input) {
|
|
80
|
+
const reported = message.usage.input + (message.usage.output ?? 0) + estimateSessionTokens(messages.slice(index + 1))
|
|
81
|
+
return Math.max(estimate, reported)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return estimate
|
|
85
|
+
}
|
|
7
86
|
|
|
8
87
|
export class CompactionService extends Service {
|
|
9
88
|
static provide = 'compaction'
|
|
@@ -17,29 +96,217 @@ export class CompactionService extends Service {
|
|
|
17
96
|
return isCompacting(sessionId)
|
|
18
97
|
}
|
|
19
98
|
|
|
20
|
-
|
|
21
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Compacts when the context is nearly full. `midTurn` is for a running turn: it checks again after
|
|
101
|
+
* tool rounds, because one long turn can fill the window without a new user message.
|
|
102
|
+
*/
|
|
103
|
+
async checkAutoCompact(sessionId: string, { midTurn = false }: { midTurn?: boolean } = {}): Promise<boolean> {
|
|
104
|
+
const session = this.ctx.sessions.current(sessionId)
|
|
105
|
+
if (!session || session.messages.length < 4 || isCompacting(sessionId)) return false
|
|
106
|
+
const settings = useSettings.getState()
|
|
107
|
+
const model = findModel(settings.provider, activeModelId(settings))
|
|
108
|
+
const ratio = usedTokens(session.messages) / contextLimit(model)
|
|
109
|
+
if (ratio < AUTO_COMPACT_THRESHOLD) return false
|
|
110
|
+
notice(`Context at ${Math.min(Math.round(ratio * 100), 100)}% — compacting before the next request…`, 'info')
|
|
111
|
+
return await this.compact(sessionId, { midTurn })
|
|
22
112
|
}
|
|
23
113
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (
|
|
114
|
+
/** Returns true when the transcript was rewritten. Never throws. */
|
|
115
|
+
async compact(sessionId: string, options: CompactOptions = {}): Promise<boolean> {
|
|
116
|
+
if (compacting.has(sessionId)) return false
|
|
117
|
+
compacting.add(sessionId)
|
|
118
|
+
useRuntime.getState().setCompacting(sessionId, true)
|
|
119
|
+
try {
|
|
120
|
+
return await this.run(sessionId, options)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
notice(`Compaction failed: ${error instanceof Error ? error.message : 'unknown error'}. Context was left unchanged.`, 'error')
|
|
123
|
+
return false
|
|
124
|
+
} finally {
|
|
125
|
+
compacting.delete(sessionId)
|
|
126
|
+
useRuntime.getState().setCompacting(sessionId, false)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async run(sessionId: string, options: CompactOptions): Promise<boolean> {
|
|
131
|
+
const session = this.ctx.sessions.current(sessionId)
|
|
132
|
+
if (!session) return false
|
|
133
|
+
const messages = session.messages
|
|
134
|
+
const settings = useSettings.getState()
|
|
135
|
+
const modelId = activeModelId(settings)
|
|
136
|
+
if (!modelId) return false
|
|
137
|
+
const model = findModel(settings.provider, modelId)
|
|
138
|
+
const limit = contextLimit(model)
|
|
139
|
+
const before = usedTokens(messages)
|
|
140
|
+
|
|
141
|
+
// Where to cut: older turns are summarised, the newest ones stay verbatim.
|
|
142
|
+
const keepTokens = Math.min(Math.floor(limit * 0.3), 60_000)
|
|
143
|
+
let plan = options.midTurn ? null : planCompaction(messages, keepTokens)
|
|
144
|
+
const last = messages[messages.length - 1]
|
|
145
|
+
if (!plan) {
|
|
146
|
+
if (messages.length < 4) {
|
|
147
|
+
if (options.manual) notice('Nothing to compact yet.', 'info')
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
// One long turn: summarise everything before the newest user message, or (mid-turn) everything.
|
|
151
|
+
if (last.role === 'user' && !options.midTurn) {
|
|
152
|
+
plan = { cut: messages.length - 1, head: messages.slice(0, -1), tail: [last], midTurn: false }
|
|
153
|
+
} else {
|
|
154
|
+
plan = { cut: messages.length, head: messages.slice(), tail: [], midTurn: true }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (plan.head.length < 3) {
|
|
158
|
+
if (options.manual) notice('Nothing to compact yet.', 'info')
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const previous = previousSummary(plan.head)
|
|
163
|
+
const fresh = plan.head.slice(previous?.skip ?? 0)
|
|
164
|
+
const files = collectFiles(fresh, previous?.files ?? [])
|
|
165
|
+
|
|
166
|
+
let summary: string
|
|
167
|
+
let fallback = false
|
|
168
|
+
try {
|
|
169
|
+
summary = await this.summarise(model, previous?.text ?? '', fresh, options.focus)
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const ratio = before / limit
|
|
172
|
+
if (!options.manual && ratio < 0.95) {
|
|
173
|
+
notice(`Auto-compact failed: ${error instanceof Error ? error.message : 'unknown error'}. Continuing with full context.`, 'error')
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
if (options.manual) {
|
|
177
|
+
notice(`Compaction failed: ${error instanceof Error ? error.message : 'unknown error'}.`, 'error')
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
summary = fallbackSummary(fresh, previous?.text ?? '', files)
|
|
181
|
+
fallback = true
|
|
182
|
+
}
|
|
183
|
+
if (!summary.trim()) return false
|
|
184
|
+
|
|
185
|
+
const now = Date.now()
|
|
186
|
+
const trigger = options.midTurn ? 'mid-turn' : options.manual ? 'manual' : 'auto'
|
|
187
|
+
const first = messages.find((message): message is UserMessage => message.role === 'user')
|
|
188
|
+
const next: Message[] = []
|
|
189
|
+
if (first) {
|
|
190
|
+
const names = (first.attachments ?? []).map((item) => item.name)
|
|
191
|
+
next.push({
|
|
192
|
+
...first,
|
|
193
|
+
content: `${clip(first.content, 8_000)}${names.length ? `\n[Attachments: ${names.join(', ')}]` : ''}`,
|
|
194
|
+
attachments: undefined,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
const summaryMessage: AssistantMessage = {
|
|
198
|
+
id: createId('msg'),
|
|
199
|
+
role: 'assistant',
|
|
200
|
+
content: `${SUMMARY_PREFIX}${summary.trim()}${files.length ? `\n\n## Files changed (tracked)\n${files.map((file) => `- ${file}`).join('\n')}` : ''}`,
|
|
201
|
+
reasoning: '',
|
|
202
|
+
toolCalls: [],
|
|
203
|
+
round: 0,
|
|
204
|
+
model: modelId,
|
|
205
|
+
provider: settings.provider,
|
|
206
|
+
createdAt: now,
|
|
207
|
+
durationMs: 0,
|
|
208
|
+
compaction: {
|
|
209
|
+
files,
|
|
210
|
+
turns: plan.head.filter((message) => message.role === 'user').length,
|
|
211
|
+
toolCalls: plan.head.filter((message) => message.role === 'tool').length,
|
|
212
|
+
at: now,
|
|
213
|
+
trigger,
|
|
214
|
+
fallback: fallback || undefined,
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
next.push(summaryMessage)
|
|
218
|
+
|
|
219
|
+
if (plan.tail.length > 0) {
|
|
220
|
+
next.push(...plan.tail)
|
|
221
|
+
} else if (options.midTurn) {
|
|
222
|
+
const lastUser = [...messages].reverse().find((message): message is UserMessage => message.role === 'user')
|
|
223
|
+
const steps = recentSteps(messages)
|
|
224
|
+
next.push({
|
|
225
|
+
id: createId('msg'),
|
|
226
|
+
role: 'user',
|
|
227
|
+
content: [
|
|
228
|
+
'[Context compacted while you were working. The summary above is the record so far.]',
|
|
229
|
+
lastUser ? `The request you are working on:\n${clip(lastUser.content.trim(), 6_000)}` : '',
|
|
230
|
+
steps ? `Most recent steps (verbatim):\n${steps}` : '',
|
|
231
|
+
'Continue from exactly here. Do not redo completed work.',
|
|
232
|
+
]
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.join('\n\n'),
|
|
235
|
+
attachments: lastUser?.attachments,
|
|
236
|
+
createdAt: now,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const after = tokensOf(next) + 2_000
|
|
241
|
+
if (after >= before) {
|
|
242
|
+
if (options.manual) notice('Compaction would not free any space, so nothing changed.', 'info')
|
|
243
|
+
return false
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Rewrite only if the transcript did not change while we were summarising.
|
|
247
|
+
const latest = this.ctx.sessions.current(sessionId)
|
|
248
|
+
if (!latest || latest.messages.length !== messages.length || latest.messages[latest.messages.length - 1]?.id !== last.id) {
|
|
249
|
+
if (options.manual) notice('The conversation changed while compacting, so nothing was changed. Try again.', 'info')
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
this.ctx.sessions.updateSession(sessionId, { messages: next })
|
|
253
|
+
const kept = plan.tail.filter((message) => message.role === 'user').length
|
|
254
|
+
notice(
|
|
255
|
+
`Context compacted: ${formatK(before)} → ${formatK(after)} tokens${kept > 1 ? ` · kept the last ${kept} turns` : ''}${fallback ? ' · summary built without the model' : ''}.`,
|
|
256
|
+
fallback ? 'info' : 'success',
|
|
257
|
+
)
|
|
258
|
+
return true
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Rolling summary: each chunk of transcript updates the running summary, so huge sessions still fit. */
|
|
262
|
+
private async summarise(model: ModelInfo | null, previous: string, messages: Message[], focus?: string): Promise<string> {
|
|
263
|
+
const maxChars = Math.min(Math.floor(contextLimit(model) * 0.45 * 3), 120_000)
|
|
264
|
+
const chunks = chunkParts(transcriptParts(messages), Math.max(maxChars, 12_000))
|
|
265
|
+
let summary = previous
|
|
266
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
267
|
+
const last = index === chunks.length - 1
|
|
268
|
+
const request = [
|
|
269
|
+
summary ? `<previous_summary>\n${summary}\n</previous_summary>` : '',
|
|
270
|
+
`<new_transcript part="${index + 1}/${chunks.length}">\n${chunks[index]}\n</new_transcript>`,
|
|
271
|
+
focus?.trim() && last ? `<focus>\n${focus.trim()}\n</focus>\nGive extra weight to the focus above.` : '',
|
|
272
|
+
'Write the updated summary now.',
|
|
273
|
+
]
|
|
274
|
+
.filter(Boolean)
|
|
275
|
+
.join('\n\n')
|
|
276
|
+
summary = await this.complete(model, request)
|
|
277
|
+
}
|
|
278
|
+
return summary
|
|
279
|
+
}
|
|
27
280
|
|
|
281
|
+
private async complete(model: ModelInfo | null, prompt: string): Promise<string> {
|
|
28
282
|
const settings = useSettings.getState()
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
283
|
+
const controller = new AbortController()
|
|
284
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
285
|
+
let text = ''
|
|
286
|
+
try {
|
|
287
|
+
const result = await this.ctx.llm.stream({
|
|
288
|
+
settings,
|
|
289
|
+
model,
|
|
290
|
+
history: [{ id: createId('msg'), role: 'user', content: prompt, createdAt: Date.now() }],
|
|
291
|
+
system: SYSTEM,
|
|
292
|
+
mode: 'plan',
|
|
293
|
+
tools: [],
|
|
294
|
+
signal: controller.signal,
|
|
295
|
+
handlers: {
|
|
296
|
+
onText: (delta) => {
|
|
297
|
+
text += delta
|
|
298
|
+
},
|
|
299
|
+
onReasoning: () => undefined,
|
|
300
|
+
onToolCalls: () => undefined,
|
|
301
|
+
onUsage: () => undefined,
|
|
38
302
|
},
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
|
|
303
|
+
})
|
|
304
|
+
return (result.content || text).trim()
|
|
305
|
+
} catch (error) {
|
|
306
|
+
const message = error instanceof Error ? error.message : ''
|
|
307
|
+
if (attempt >= TRANSIENT_ATTEMPTS || !classifyProviderError(message).retry) throw error
|
|
308
|
+
await sleep(backoffMs(attempt, message))
|
|
309
|
+
}
|
|
42
310
|
}
|
|
43
|
-
return false
|
|
44
311
|
}
|
|
45
312
|
}
|
package/src/core/services/llm.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { streamGemini } from '../../agent/adapters/gemini'
|
|
|
5
5
|
import { streamOpenAI } from '../../agent/adapters/openai'
|
|
6
6
|
import { streamOpenAIResponses } from '../../agent/adapters/openai-responses'
|
|
7
7
|
import { apiKindFor } from '../../store/models'
|
|
8
|
-
import type { ModelRequest } from '../types'
|
|
8
|
+
import type { ModelRequest, ToolSchema } from '../types'
|
|
9
9
|
|
|
10
10
|
export class LlmService extends Service {
|
|
11
11
|
static provide = 'llm'
|
|
@@ -14,7 +14,11 @@ export class LlmService extends Service {
|
|
|
14
14
|
super(ctx, 'llm')
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
async stream(
|
|
17
|
+
async stream(input: ModelRequest): Promise<AdapterResult> {
|
|
18
|
+
const request: ModelRequest & { tools: ToolSchema[] } = {
|
|
19
|
+
...input,
|
|
20
|
+
tools: input.tools ?? this.ctx.tools.schemas(input.settings, input.mode),
|
|
21
|
+
}
|
|
18
22
|
return await this.ctx.events.waterfall('agent/request', request, async () => {
|
|
19
23
|
const kind = apiKindFor(request.settings.provider, request.settings.model, request.model)
|
|
20
24
|
switch (kind) {
|
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
import { Service, type Context } from 'cordis'
|
|
2
|
-
import {
|
|
2
|
+
import type { AssistantMessage, Message, ToolCall, ToolMessage, UserMessage } from '../../types'
|
|
3
|
+
import { createId } from '../../store/chats'
|
|
4
|
+
import { activeModelId, useSettings } from '../../store/settings'
|
|
5
|
+
import { findModel } from '../../store/models'
|
|
6
|
+
import { contextLimit } from '../../lib/context'
|
|
7
|
+
import { clip, tokensOf } from '../../lib/compact-plan'
|
|
8
|
+
import { MAX_RETRIES, backoffMs, classifyProviderError } from '../../lib/retry'
|
|
9
|
+
import { toolSummary } from '../../lib/tool-meta'
|
|
10
|
+
import { SUBAGENT_TYPES, type SubagentType } from '../../lib/subagent-types'
|
|
11
|
+
|
|
12
|
+
const TOOL_OUTPUT_LIMIT = 12_000
|
|
13
|
+
const RECENT_RESULTS_KEPT = 8
|
|
14
|
+
|
|
15
|
+
export interface SubagentRequest {
|
|
16
|
+
task: string
|
|
17
|
+
context?: string
|
|
18
|
+
type?: SubagentType
|
|
19
|
+
signal: AbortSignal
|
|
20
|
+
sessionId?: string
|
|
21
|
+
/** Receives a short live log of what the subagent is doing. */
|
|
22
|
+
onProgress?: (text: string) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SubagentReport {
|
|
26
|
+
output: string
|
|
27
|
+
rounds: number
|
|
28
|
+
toolCalls: number
|
|
29
|
+
/** Stopped at the round cap and asked to report with what it had. */
|
|
30
|
+
capped: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function abortError() {
|
|
34
|
+
return new DOMException('Aborted', 'AbortError')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const finish = () => {
|
|
40
|
+
window.clearTimeout(timer)
|
|
41
|
+
signal.removeEventListener('abort', finish)
|
|
42
|
+
resolve()
|
|
43
|
+
}
|
|
44
|
+
const timer = window.setTimeout(finish, ms)
|
|
45
|
+
signal.addEventListener('abort', finish, { once: true })
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Replaces old tool outputs with a stub so a long investigation never overflows the window. */
|
|
50
|
+
function pruneHistory(history: Message[], budgetTokens: number): void {
|
|
51
|
+
if (tokensOf(history) <= budgetTokens) return
|
|
52
|
+
const tools = history.filter((message): message is ToolMessage => message.role === 'tool')
|
|
53
|
+
for (const message of tools.slice(0, Math.max(0, tools.length - RECENT_RESULTS_KEPT))) {
|
|
54
|
+
if (message.result && message.result.length > 200) {
|
|
55
|
+
message.result = `[earlier output of ${message.call.name} pruned: ${message.result.length} characters]`
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
3
59
|
|
|
4
60
|
export class SubagentService extends Service {
|
|
5
61
|
static provide = 'subagents'
|
|
@@ -9,7 +65,139 @@ export class SubagentService extends Service {
|
|
|
9
65
|
super(ctx, 'subagents')
|
|
10
66
|
}
|
|
11
67
|
|
|
12
|
-
async run(
|
|
13
|
-
|
|
68
|
+
async run(request: SubagentRequest): Promise<SubagentReport> {
|
|
69
|
+
const { signal, onProgress } = request
|
|
70
|
+
const task = request.task.trim()
|
|
71
|
+
if (!task) return { output: 'The subagent was not given a task.', rounds: 0, toolCalls: 0, capped: false }
|
|
72
|
+
if (signal.aborted) throw abortError()
|
|
73
|
+
|
|
74
|
+
const type = request.type ?? 'explore'
|
|
75
|
+
const spec = SUBAGENT_TYPES[type]
|
|
76
|
+
const settings = useSettings.getState()
|
|
77
|
+
const model = findModel(settings.provider, activeModelId(settings))
|
|
78
|
+
const system = this.ctx.systemPrompt.buildSubagentPrompt(settings, request.sessionId, type)
|
|
79
|
+
const allowed = new Set<string>(spec.tools)
|
|
80
|
+
const tools = this.ctx.tools.schemas(settings, 'plan', { subagent: true }).filter((tool) => allowed.has(tool.name))
|
|
81
|
+
const budget = Math.floor(contextLimit(model) * 0.6)
|
|
82
|
+
|
|
83
|
+
const history: Message[] = [
|
|
84
|
+
{
|
|
85
|
+
id: createId('sub'),
|
|
86
|
+
role: 'user',
|
|
87
|
+
content: [
|
|
88
|
+
`Task (${spec.label.toLowerCase()}): ${task}`,
|
|
89
|
+
request.context?.trim() ? `\nContext from the main agent:\n${request.context.trim()}` : '',
|
|
90
|
+
'\nInvestigate now, then write the report.',
|
|
91
|
+
]
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join('\n'),
|
|
94
|
+
createdAt: Date.now(),
|
|
95
|
+
} satisfies UserMessage,
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
const log: string[] = []
|
|
99
|
+
const progress = (line: string) => {
|
|
100
|
+
log.push(line)
|
|
101
|
+
onProgress?.(`${spec.label} subagent\n${log.slice(-14).join('\n')}`)
|
|
102
|
+
}
|
|
103
|
+
progress('starting…')
|
|
104
|
+
|
|
105
|
+
let totalCalls = 0
|
|
106
|
+
let capped = false
|
|
107
|
+
for (let round = 0; ; round += 1) {
|
|
108
|
+
if (signal.aborted) throw abortError()
|
|
109
|
+
// On the last round, withdraw the tools so the model has to write its report.
|
|
110
|
+
const finalRound = round >= spec.maxRounds
|
|
111
|
+
if (finalRound) {
|
|
112
|
+
capped = true
|
|
113
|
+
history.push({
|
|
114
|
+
id: createId('sub'),
|
|
115
|
+
role: 'user',
|
|
116
|
+
content: 'You have used your investigation budget. Write the final report now from what you found, and say what is still unverified.',
|
|
117
|
+
createdAt: Date.now(),
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
pruneHistory(history, budget)
|
|
121
|
+
const result = await this.stream(system, history, finalRound ? [] : tools, signal, model)
|
|
122
|
+
const assistant: AssistantMessage = {
|
|
123
|
+
id: createId('sub'),
|
|
124
|
+
role: 'assistant',
|
|
125
|
+
content: result.content,
|
|
126
|
+
reasoning: result.reasoning,
|
|
127
|
+
thinkingBlocks: result.thinkingBlocks,
|
|
128
|
+
toolCalls: result.toolCalls,
|
|
129
|
+
round,
|
|
130
|
+
createdAt: Date.now(),
|
|
131
|
+
}
|
|
132
|
+
history.push(assistant)
|
|
133
|
+
|
|
134
|
+
if (result.toolCalls.length === 0 || finalRound) {
|
|
135
|
+
const report = assistant.content.trim()
|
|
136
|
+
return {
|
|
137
|
+
output: report || 'The subagent returned no report.',
|
|
138
|
+
rounds: round + 1,
|
|
139
|
+
toolCalls: totalCalls,
|
|
140
|
+
capped,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Read-only calls are independent, so they run together.
|
|
145
|
+
const executed = await Promise.all(
|
|
146
|
+
result.toolCalls.map(async (call: ToolCall): Promise<ToolMessage> => {
|
|
147
|
+
totalCalls += 1
|
|
148
|
+
progress(`${call.name} ${clip(toolSummary(call.name, call.args), 90)}`)
|
|
149
|
+
if (!allowed.has(call.name)) {
|
|
150
|
+
return this.toolMessage(call, round, 'error', `Tool "${call.name}" is not available to this subagent.`)
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const output = await this.ctx.tools.execute(call.name, call.args, settings, 'plan', signal)
|
|
154
|
+
return this.toolMessage(call, round, output.isError ? 'error' : 'done', clip(output.output, TOOL_OUTPUT_LIMIT))
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error instanceof DOMException && error.name === 'AbortError') throw error
|
|
157
|
+
return this.toolMessage(call, round, 'error', error instanceof Error ? error.message : 'The tool failed.')
|
|
158
|
+
}
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
history.push(...executed)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private toolMessage(call: ToolCall, round: number, status: 'done' | 'error', result: string): ToolMessage {
|
|
166
|
+
return { id: createId('sub'), role: 'tool', call, status, result, round, createdAt: Date.now() }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One model call with the same transient-error retry as the main loop. */
|
|
170
|
+
private async stream(
|
|
171
|
+
system: string,
|
|
172
|
+
history: Message[],
|
|
173
|
+
tools: ReturnType<Context['tools']['schemas']>,
|
|
174
|
+
signal: AbortSignal,
|
|
175
|
+
model: ReturnType<typeof findModel>,
|
|
176
|
+
) {
|
|
177
|
+
const settings = useSettings.getState()
|
|
178
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
179
|
+
try {
|
|
180
|
+
return await this.ctx.llm.stream({
|
|
181
|
+
settings,
|
|
182
|
+
model,
|
|
183
|
+
history,
|
|
184
|
+
system,
|
|
185
|
+
mode: 'plan',
|
|
186
|
+
tools,
|
|
187
|
+
signal,
|
|
188
|
+
handlers: {
|
|
189
|
+
onText: () => undefined,
|
|
190
|
+
onReasoning: () => undefined,
|
|
191
|
+
onToolCalls: () => undefined,
|
|
192
|
+
onUsage: () => undefined,
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (signal.aborted) throw abortError()
|
|
197
|
+
const message = error instanceof Error ? error.message : ''
|
|
198
|
+
if (attempt > MAX_RETRIES || !classifyProviderError(message).retry) throw error
|
|
199
|
+
await sleep(backoffMs(attempt, message), signal)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
14
202
|
}
|
|
15
203
|
}
|
|
@@ -4,7 +4,30 @@ import { activeModelId } from '../../store/settings'
|
|
|
4
4
|
import { useWorkspace } from '../../store/workspace'
|
|
5
5
|
import { skillCatalogPrompt } from '../../store/skills'
|
|
6
6
|
import { resolveBasePrompt } from '../../lib/system-prompt'
|
|
7
|
+
import { useSettings } from '../../store/settings'
|
|
7
8
|
import type { PromptSection } from '../types'
|
|
9
|
+
import { SUBAGENT_TYPES, type SubagentType } from '../../lib/subagent-types'
|
|
10
|
+
|
|
11
|
+
function environmentContext(): string {
|
|
12
|
+
const daemon = useSettings.getState().daemon
|
|
13
|
+
const os = daemon?.os ?? ''
|
|
14
|
+
const shell = daemon?.shell ?? ''
|
|
15
|
+
if (!os && !shell) return ''
|
|
16
|
+
const guide =
|
|
17
|
+
os === 'win32'
|
|
18
|
+
? [
|
|
19
|
+
'- Commands run in PowerShell, not bash. Use PowerShell syntax: `Get-ChildItem`, `Get-Content`, `Select-String`, `$env:NAME`, `;` (not `&&` on Windows PowerShell 5).',
|
|
20
|
+
'- Do not assume `ls -la`, `grep`, `sed`, `cat <<EOF` or `export` exist. Paths may use `\\` or `/`; quote paths that contain spaces.',
|
|
21
|
+
]
|
|
22
|
+
: os === 'darwin'
|
|
23
|
+
? [
|
|
24
|
+
'- Commands run in a login shell on macOS (BSD userland). `sed -i` needs an argument (`sed -i \'\'`), `date`, `xargs` and `grep` differ from GNU; prefer portable flags or the dedicated file tools.',
|
|
25
|
+
]
|
|
26
|
+
: os === 'linux'
|
|
27
|
+
? ['- Commands run in bash with GNU userland.']
|
|
28
|
+
: []
|
|
29
|
+
return ['## Environment', `- OS: ${os || 'unknown'}${daemon?.arch ? ` (${daemon.arch})` : ''}`, `- Shell: ${shell || 'unknown'}`, ...guide].join('\n')
|
|
30
|
+
}
|
|
8
31
|
|
|
9
32
|
function workspaceContext(): string {
|
|
10
33
|
const state = useWorkspace.getState()
|
|
@@ -31,7 +54,6 @@ function workspaceContext(): string {
|
|
|
31
54
|
|
|
32
55
|
export class SystemPromptService extends Service {
|
|
33
56
|
static provide = 'systemPrompt'
|
|
34
|
-
static inject = ['sessions']
|
|
35
57
|
private customSections = new Map<string, (settings: AppSettings, mode: AgentMode) => PromptSection | null>()
|
|
36
58
|
|
|
37
59
|
constructor(ctx: Context) {
|
|
@@ -89,6 +111,10 @@ export class SystemPromptService extends Service {
|
|
|
89
111
|
})
|
|
90
112
|
}
|
|
91
113
|
|
|
114
|
+
// 3c. Operating system and shell
|
|
115
|
+
const environment = environmentContext()
|
|
116
|
+
if (environment) sections.push({ name: 'environment', priority: 38, content: environment })
|
|
117
|
+
|
|
92
118
|
// 4. Mode
|
|
93
119
|
sections.push({
|
|
94
120
|
name: 'mode',
|
|
@@ -157,16 +183,17 @@ export class SystemPromptService extends Service {
|
|
|
157
183
|
})
|
|
158
184
|
}
|
|
159
185
|
|
|
160
|
-
buildSubagentPrompt(settings: AppSettings, sessionId?: string): string {
|
|
186
|
+
buildSubagentPrompt(settings: AppSettings, sessionId?: string, type: SubagentType = 'explore'): string {
|
|
187
|
+
const spec = SUBAGENT_TYPES[type]
|
|
161
188
|
return [
|
|
162
189
|
this.assemble(settings, 'plan', sessionId),
|
|
163
190
|
[
|
|
164
|
-
|
|
165
|
-
'You are a focused read-only investigator.
|
|
166
|
-
'-
|
|
167
|
-
|
|
168
|
-
'- Be
|
|
169
|
-
'- Do not repeat the task
|
|
191
|
+
`## Subagent (${spec.label})`,
|
|
192
|
+
'You are a focused, read-only investigator working for the main agent. You cannot ask the user questions and cannot start other subagents.',
|
|
193
|
+
'- Work only on the assigned task. Do not edit files, change git state or run commands that mutate the machine; the daemon enforces this.',
|
|
194
|
+
...spec.guidance.map((line) => `- ${line}`),
|
|
195
|
+
'- Be efficient: you have a limited number of rounds. Batch independent tool calls in one round.',
|
|
196
|
+
'- Finish with a concise report: the answer first, then evidence (paths, lines, URLs), then open questions. Do not repeat the task. Do not invent evidence; say what could not be verified.',
|
|
170
197
|
].join('\n'),
|
|
171
198
|
].join('\n\n')
|
|
172
199
|
}
|
|
@@ -78,8 +78,9 @@ export class ToolService extends Service {
|
|
|
78
78
|
signal: AbortSignal,
|
|
79
79
|
callId?: string,
|
|
80
80
|
turnId?: string,
|
|
81
|
+
extra: Pick<ToolExecutionContext, 'sessionId' | 'onProgress'> = {},
|
|
81
82
|
): Promise<ToolResult> {
|
|
82
|
-
const exec: ToolExecutionContext = { name, args, settings, mode, signal, callId, turnId }
|
|
83
|
+
const exec: ToolExecutionContext = { name, args, settings, mode, signal, callId, turnId, ...extra }
|
|
83
84
|
|
|
84
85
|
// 1. tools/pre-execute waterfall (approval, permission gate, mode checks)
|
|
85
86
|
const preDecision: ToolPreExecuteResult = await this.ctx.events.waterfall(
|
package/src/core/types.ts
CHANGED
|
@@ -46,6 +46,9 @@ export interface ToolExecutionContext {
|
|
|
46
46
|
callId?: string
|
|
47
47
|
/** The user message that started this turn; file edits are checkpointed under it. */
|
|
48
48
|
turnId?: string
|
|
49
|
+
sessionId?: string
|
|
50
|
+
/** Lets a long tool (a subagent) show live progress in its tool card. */
|
|
51
|
+
onProgress?: (text: string) => void
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
export type ToolPreExecuteDecision = 'allow' | 'ask' | 'deny'
|
|
@@ -89,6 +92,8 @@ export interface ModelRequest {
|
|
|
89
92
|
system: string
|
|
90
93
|
mode: AgentMode
|
|
91
94
|
signal: AbortSignal
|
|
95
|
+
/** Tools to offer. Defaults to the tools allowed for `mode`; pass [] for a plain completion. */
|
|
96
|
+
tools?: ToolSchema[]
|
|
92
97
|
handlers: {
|
|
93
98
|
onText: (delta: string) => void
|
|
94
99
|
onReasoning: (delta: string) => void
|