@bibobonking/opensol 3.1.5 → 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.
Files changed (53) hide show
  1. package/CHANGELOG.md +8 -2
  2. package/LICENSE +21 -0
  3. package/README.md +24 -20
  4. package/desktop/README.md +1 -1
  5. package/dist/assets/{index-C0yvs6Vl.js → index-BUVnnhRH.js} +1 -1
  6. package/dist/assets/index-CyxlAgKM.js +271 -0
  7. package/dist/assets/index-DFeF90S8.css +1 -0
  8. package/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/server/README.md +15 -68
  11. package/server/daemon.mjs +6 -1
  12. package/server/lib/deploy.mjs +5 -10
  13. package/server/lib/shell.mjs +149 -0
  14. package/server/lib/shell.test.mjs +72 -0
  15. package/server/lib/tools.mjs +8 -44
  16. package/src/agent/adapters/anthropic.ts +2 -3
  17. package/src/agent/adapters/gemini.ts +2 -3
  18. package/src/agent/adapters/openai-responses.ts +2 -3
  19. package/src/agent/adapters/openai.ts +2 -3
  20. package/src/agent/adapters/shared.ts +3 -0
  21. package/src/components/ChatView.tsx +17 -54
  22. package/src/components/Composer.tsx +143 -135
  23. package/src/components/ContextBar.tsx +2 -2
  24. package/src/components/HomeDashboard.tsx +7 -4
  25. package/src/components/MessageItem.tsx +33 -1
  26. package/src/components/ModelDeck.tsx +5 -27
  27. package/src/components/SettingsModal.tsx +8 -0
  28. package/src/components/ToolCallCard.tsx +2 -2
  29. package/src/components/TrajectoryView.tsx +1 -1
  30. package/src/components/composer/PlusMenu.tsx +117 -0
  31. package/src/components/composer/Popover.tsx +109 -0
  32. package/src/components/composer/WorkspacePicker.tsx +102 -0
  33. package/src/core/plugins/tools/shell.ts +2 -2
  34. package/src/core/plugins/tools/subagent.ts +18 -8
  35. package/src/core/services/agent-loop.ts +45 -43
  36. package/src/core/services/compaction.ts +289 -22
  37. package/src/core/services/llm.ts +6 -2
  38. package/src/core/services/subagents.ts +191 -3
  39. package/src/core/services/system-prompt.ts +35 -7
  40. package/src/core/services/tools.ts +2 -1
  41. package/src/core/types.ts +5 -0
  42. package/src/lib/compact-plan.test.mjs +62 -0
  43. package/src/lib/compact-plan.ts +188 -0
  44. package/src/lib/subagent-types.ts +54 -0
  45. package/src/lib/tool-meta.ts +40 -0
  46. package/src/store/settings.ts +3 -0
  47. package/src/store/ui.ts +12 -0
  48. package/src/types.ts +26 -0
  49. package/shared/plugin-builder.test.mjs +0 -43
  50. package/shared/plugin-package.d.mts +0 -34
  51. package/shared/plugin-package.mjs +0 -197
  52. package/shared/plugin-package.test.mjs +0 -94
  53. package/tsconfig.tsbuildinfo +0 -1
@@ -1,9 +1,88 @@
1
1
  import { Service, type Context } from 'cordis'
2
- import { compactSession, isCompacting } from '../../agent/compact'
3
- import { contextUsageRatio, AUTO_COMPACT_THRESHOLD } from '../../lib/context'
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 { useChats } from '../../store/chats'
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
- async compact(sessionId: string): Promise<boolean> {
21
- return await compactSession(sessionId)
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
- async checkAutoCompact(sessionId: string): Promise<boolean> {
25
- const session = useChats.getState().sessions.find((item) => item.id === sessionId)
26
- if (!session || session.messages.length < 4 || this.isCompacting(sessionId)) return false
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 modelInfo = findModel(settings.provider, activeModelId(settings))
30
- const ratio = contextUsageRatio(session.messages, modelInfo)
31
-
32
- if (ratio >= AUTO_COMPACT_THRESHOLD) {
33
- window.dispatchEvent(
34
- new CustomEvent('maxcode:notice', {
35
- detail: {
36
- message: `Context at ${Math.round(ratio * 100)}% — compacting session before next request…`,
37
- tone: 'info',
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
- return await this.compact(sessionId)
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
  }
@@ -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(request: ModelRequest): Promise<AdapterResult> {
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 { runSubagent } from '../../agent/subagent'
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(task: string, context: string, signal: AbortSignal, sessionId?: string): Promise<string> {
13
- return await runSubagent(task, context, signal, sessionId)
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()
@@ -88,6 +111,10 @@ export class SystemPromptService extends Service {
88
111
  })
89
112
  }
90
113
 
114
+ // 3c. Operating system and shell
115
+ const environment = environmentContext()
116
+ if (environment) sections.push({ name: 'environment', priority: 38, content: environment })
117
+
91
118
  // 4. Mode
92
119
  sections.push({
93
120
  name: 'mode',
@@ -156,16 +183,17 @@ export class SystemPromptService extends Service {
156
183
  })
157
184
  }
158
185
 
159
- buildSubagentPrompt(settings: AppSettings, sessionId?: string): string {
186
+ buildSubagentPrompt(settings: AppSettings, sessionId?: string, type: SubagentType = 'explore'): string {
187
+ const spec = SUBAGENT_TYPES[type]
160
188
  return [
161
189
  this.assemble(settings, 'plan', sessionId),
162
190
  [
163
- '## Subagent',
164
- 'You are a focused read-only investigator. Work only on the assigned task.',
165
- '- Inspect the real workspace with read-only file, grep, shell and web tools.',
166
- '- Do not edit files, change git state or run commands that mutate the machine. The daemon enforces this.',
167
- '- Be concise: return findings, exact file paths and line references, and any uncertainty.',
168
- '- Do not repeat the task back. Do not invent evidence. If something cannot be verified, say so.',
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.',
169
197
  ].join('\n'),
170
198
  ].join('\n\n')
171
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