@bibobonking/opensol 0.0.0-stage → 3.0.2
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/.github/workflows/build.yml +25 -0
- package/README.md +74 -2
- package/dist/assets/index-BmN7pBji.css +1 -0
- package/dist/assets/index-DgNDjkOz.js +301 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +55 -0
- package/dist/preview-host.html +33 -0
- package/index.html +54 -0
- package/package.json +43 -4
- package/postcss.config.js +6 -0
- package/public/favicon.svg +15 -0
- package/public/preview-host.html +33 -0
- package/server/README.md +75 -0
- package/server/daemon.mjs +1082 -0
- package/server/lib/checkpoints.mjs +124 -0
- package/server/lib/checkpoints.test.mjs +110 -0
- package/server/lib/deploy.mjs +436 -0
- package/server/lib/http.mjs +237 -0
- package/server/lib/plugins.mjs +321 -0
- package/server/lib/plugins.test.mjs +140 -0
- package/server/lib/providers.mjs +594 -0
- package/server/lib/store.mjs +491 -0
- package/server/lib/tools.mjs +990 -0
- package/server/opensol.mjs +202 -0
- package/server/package.json +42 -0
- package/shared/plugin-builder.test.mjs +43 -0
- package/shared/plugin-package.d.mts +34 -0
- package/shared/plugin-package.mjs +197 -0
- package/shared/plugin-package.test.mjs +94 -0
- package/src/App.tsx +152 -0
- package/src/agent/adapters/anthropic.ts +233 -0
- package/src/agent/adapters/gemini.ts +225 -0
- package/src/agent/adapters/openai-responses.ts +215 -0
- package/src/agent/adapters/openai.ts +200 -0
- package/src/agent/adapters/shared.ts +96 -0
- package/src/agent/approval.ts +20 -0
- package/src/agent/compact.ts +234 -0
- package/src/agent/index.ts +36 -0
- package/src/agent/model.ts +20 -0
- package/src/agent/prompt.ts +107 -0
- package/src/agent/queue.test.mjs +111 -0
- package/src/agent/queue.ts +84 -0
- package/src/agent/subagent.ts +109 -0
- package/src/agent/tools.ts +541 -0
- package/src/components/ChatView.tsx +403 -0
- package/src/components/CommandPalette.tsx +416 -0
- package/src/components/Composer.tsx +678 -0
- package/src/components/ContextBar.tsx +58 -0
- package/src/components/DeployModal.tsx +512 -0
- package/src/components/ErrorBoundary.tsx +75 -0
- package/src/components/HomeDashboard.tsx +189 -0
- package/src/components/Logo.tsx +41 -0
- package/src/components/Markdown.tsx +232 -0
- package/src/components/MessageItem.tsx +655 -0
- package/src/components/ModelPicker.tsx +487 -0
- package/src/components/PixleSol.tsx +243 -0
- package/src/components/PluginBuilder.tsx +285 -0
- package/src/components/PluginManager.tsx +341 -0
- package/src/components/PreviewPane.tsx +166 -0
- package/src/components/SettingsModal.tsx +2052 -0
- package/src/components/Sidebar.tsx +504 -0
- package/src/components/Splash.tsx +104 -0
- package/src/components/ToolCallCard.tsx +177 -0
- package/src/components/TopBar.tsx +182 -0
- package/src/components/TrajectoryView.tsx +178 -0
- package/src/components/WorkspacePanel.tsx +710 -0
- package/src/components/ui.tsx +311 -0
- package/src/core/kernel.ts +45 -0
- package/src/core/plugins/tools/fs.ts +219 -0
- package/src/core/plugins/tools/git.ts +83 -0
- package/src/core/plugins/tools/grep.ts +98 -0
- package/src/core/plugins/tools/shell.ts +88 -0
- package/src/core/plugins/tools/subagent.ts +36 -0
- package/src/core/plugins/tools/web.ts +97 -0
- package/src/core/services/agent-loop.ts +417 -0
- package/src/core/services/approval.ts +66 -0
- package/src/core/services/compaction.ts +45 -0
- package/src/core/services/llm.ts +32 -0
- package/src/core/services/sessions.ts +47 -0
- package/src/core/services/subagents.ts +15 -0
- package/src/core/services/system-prompt.ts +177 -0
- package/src/core/services/tools.ts +135 -0
- package/src/core/services/ui.ts +109 -0
- package/src/core/types.ts +139 -0
- package/src/data/providers.ts +125 -0
- package/src/data/skills.ts +68 -0
- package/src/index.css +776 -0
- package/src/lib/boot.ts +94 -0
- package/src/lib/context.ts +75 -0
- package/src/lib/conversation.test.mjs +64 -0
- package/src/lib/conversation.ts +59 -0
- package/src/lib/daemon.ts +475 -0
- package/src/lib/hooks.ts +33 -0
- package/src/lib/permissions.ts +60 -0
- package/src/lib/pixlesol.test.mjs +51 -0
- package/src/lib/pixlesol.ts +55 -0
- package/src/lib/plugin-folder.test.mjs +145 -0
- package/src/lib/plugin-folder.ts +96 -0
- package/src/lib/reasoning.ts +87 -0
- package/src/lib/sse.ts +75 -0
- package/src/lib/themes.test.mjs +72 -0
- package/src/lib/themes.ts +52 -0
- package/src/lib/usage.test.mjs +72 -0
- package/src/lib/usage.ts +155 -0
- package/src/lib/utils.ts +73 -0
- package/src/main.tsx +16 -0
- package/src/plugins/api.test.mjs +77 -0
- package/src/plugins/api.ts +113 -0
- package/src/plugins/bridge.d.mts +1 -0
- package/src/plugins/bridge.mjs +26 -0
- package/src/plugins/bridge.test.mjs +17 -0
- package/src/plugins/index.ts +52 -0
- package/src/plugins/preview.test.mjs +18 -0
- package/src/plugins/preview.ts +34 -0
- package/src/plugins/runtime.ts +194 -0
- package/src/plugins/types.ts +40 -0
- package/src/store/chats.ts +278 -0
- package/src/store/models.ts +120 -0
- package/src/store/settings.ts +445 -0
- package/src/store/ui.ts +132 -0
- package/src/store/workspace.ts +299 -0
- package/src/types.ts +337 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +83 -0
- package/tsconfig.json +22 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { Service, type Context } from 'cordis'
|
|
2
|
+
import type { AssistantMessage, ToolMessage } from '../../types'
|
|
3
|
+
import { activeModelId, useSettings } from '../../store/settings'
|
|
4
|
+
import { createId } from '../../store/chats'
|
|
5
|
+
import { findModel } from '../../store/models'
|
|
6
|
+
import { useRuntime } from '../../store/ui'
|
|
7
|
+
import { getProviderName } from '../../data/providers'
|
|
8
|
+
import { describeRequestError } from '../../lib/sse'
|
|
9
|
+
import type { AdapterResult } from '../../agent/adapters/shared'
|
|
10
|
+
import { createTurnQueue, lastUserMessageId } from '../../agent/queue'
|
|
11
|
+
import type { StepContext, StepResult, TurnResult } from '../types'
|
|
12
|
+
|
|
13
|
+
const MAX_ROUNDS = 16
|
|
14
|
+
const MAX_RETRIES = 2
|
|
15
|
+
/** Provider hiccups worth another try: overload, rate limits, 5xx and dropped connections. */
|
|
16
|
+
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
|
|
17
|
+
|
|
18
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const finish = () => {
|
|
21
|
+
window.clearTimeout(timer)
|
|
22
|
+
signal.removeEventListener('abort', finish)
|
|
23
|
+
resolve()
|
|
24
|
+
}
|
|
25
|
+
const timer = window.setTimeout(finish, ms)
|
|
26
|
+
signal.addEventListener('abort', finish, { once: true })
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isAbortError(error: unknown) {
|
|
31
|
+
return (
|
|
32
|
+
(error instanceof DOMException && error.name === 'AbortError') ||
|
|
33
|
+
(error instanceof Error && error.name === 'AbortError')
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function truncate(value: string, limit = 40_000) {
|
|
38
|
+
if (value.length <= limit) return value
|
|
39
|
+
return `${value.slice(0, limit)}\n… truncated (${value.length - limit} more characters)`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class AgentLoopService extends Service {
|
|
43
|
+
static provide = 'agentLoop'
|
|
44
|
+
static inject = ['sessions', 'systemPrompt', 'tools', 'llm', 'approval', 'compaction', 'subagents']
|
|
45
|
+
|
|
46
|
+
private controllers = new Map<string, AbortController>()
|
|
47
|
+
private queue = createTurnQueue((sessionId, turnId) => this.runTurn(sessionId, turnId))
|
|
48
|
+
|
|
49
|
+
constructor(ctx: Context) {
|
|
50
|
+
super(ctx, 'agentLoop')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
isBusy(sessionId: string): boolean {
|
|
54
|
+
return this.queue.isBusy(sessionId)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
stopRun(sessionId: string): void {
|
|
58
|
+
this.queue.clear(sessionId)
|
|
59
|
+
this.controllers.get(sessionId)?.abort()
|
|
60
|
+
void this.ctx.events.serial('agent/turn-stopping', sessionId)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
stopAllRuns(): void {
|
|
64
|
+
this.queue.clearAll()
|
|
65
|
+
this.controllers.forEach((controller, sessionId) => {
|
|
66
|
+
controller.abort()
|
|
67
|
+
void this.ctx.events.serial('agent/turn-stopping', sessionId)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
runAgent(sessionId: string, userMessageId?: string): void {
|
|
72
|
+
const session = this.ctx.sessions.current(sessionId)
|
|
73
|
+
if (!session) return
|
|
74
|
+
const turnId = userMessageId ?? lastUserMessageId(session.messages)
|
|
75
|
+
if (!turnId) return
|
|
76
|
+
void this.queue.push(sessionId, turnId)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
regenerateLastTurn(sessionId: string): boolean {
|
|
80
|
+
const session = this.ctx.sessions.current(sessionId)
|
|
81
|
+
if (!session || this.queue.isBusy(sessionId)) return false
|
|
82
|
+
let lastUser = -1
|
|
83
|
+
for (let index = session.messages.length - 1; index >= 0; index -= 1) {
|
|
84
|
+
if (session.messages[index].role === 'user') {
|
|
85
|
+
lastUser = index
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (lastUser < 0) return false
|
|
90
|
+
const userMessageId = session.messages[lastUser].id
|
|
91
|
+
this.ctx.sessions.updateSession(sessionId, {
|
|
92
|
+
messages: session.messages.slice(0, lastUser + 1),
|
|
93
|
+
})
|
|
94
|
+
this.runAgent(sessionId, userMessageId)
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async runTurn(sessionId: string, userMessageId: string): Promise<void> {
|
|
99
|
+
const session = this.ctx.sessions.current(sessionId)
|
|
100
|
+
if (!session) return
|
|
101
|
+
if (!this.ctx.sessions.turnHistory(session.messages, userMessageId)) return
|
|
102
|
+
|
|
103
|
+
const controller = new AbortController()
|
|
104
|
+
this.controllers.set(sessionId, controller)
|
|
105
|
+
useRuntime.getState().setStreaming(sessionId, true)
|
|
106
|
+
const settings = useSettings.getState()
|
|
107
|
+
const providerName = getProviderName(settings.provider)
|
|
108
|
+
const mode = session.mode ?? settings.mode
|
|
109
|
+
const startedAt = Date.now()
|
|
110
|
+
let totalRounds = 0
|
|
111
|
+
let turnError: string | undefined
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
rounds: for (let round = 0; round < MAX_ROUNDS; round += 1) {
|
|
115
|
+
totalRounds = round + 1
|
|
116
|
+
if (controller.signal.aborted) break
|
|
117
|
+
let current = this.ctx.sessions.current(sessionId)
|
|
118
|
+
if (!current) break
|
|
119
|
+
|
|
120
|
+
// Auto-compaction check before round 0
|
|
121
|
+
// A prompt queued behind this turn would be dropped by the rewrite, so wait until it is the last one.
|
|
122
|
+
if (round === 0 && lastUserMessageId(current.messages) === userMessageId) {
|
|
123
|
+
const compacted = await this.ctx.compaction.checkAutoCompact(sessionId)
|
|
124
|
+
if (compacted) {
|
|
125
|
+
// Compaction rewrites the whole transcript, so the original user message
|
|
126
|
+
// is gone: continue from the handoff message and the fresh session state.
|
|
127
|
+
const refreshed = this.ctx.sessions.current(sessionId)
|
|
128
|
+
if (!refreshed) break
|
|
129
|
+
const handoffId = lastUserMessageId(refreshed.messages)
|
|
130
|
+
if (!handoffId) break
|
|
131
|
+
userMessageId = handoffId
|
|
132
|
+
current = refreshed
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const history = this.ctx.sessions.turnHistory(current.messages, userMessageId)
|
|
137
|
+
if (!history) break
|
|
138
|
+
|
|
139
|
+
const assistantId = createId('msg')
|
|
140
|
+
const assistant: AssistantMessage = {
|
|
141
|
+
id: assistantId,
|
|
142
|
+
role: 'assistant',
|
|
143
|
+
content: '',
|
|
144
|
+
reasoning: '',
|
|
145
|
+
toolCalls: [],
|
|
146
|
+
round,
|
|
147
|
+
model: activeModelId(settings),
|
|
148
|
+
provider: settings.provider,
|
|
149
|
+
createdAt: Date.now(),
|
|
150
|
+
}
|
|
151
|
+
this.ctx.sessions.addMessage(sessionId, assistant)
|
|
152
|
+
|
|
153
|
+
let streamedContent = ''
|
|
154
|
+
let streamedReasoning = ''
|
|
155
|
+
let flushTimer: number | undefined
|
|
156
|
+
let usage: AdapterResult['usage'] = {}
|
|
157
|
+
|
|
158
|
+
const flush = () => {
|
|
159
|
+
if (flushTimer !== undefined) return
|
|
160
|
+
flushTimer = window.setTimeout(() => {
|
|
161
|
+
flushTimer = undefined
|
|
162
|
+
this.ctx.sessions.updateMessage(sessionId, assistantId, {
|
|
163
|
+
content: streamedContent,
|
|
164
|
+
reasoning: streamedReasoning,
|
|
165
|
+
})
|
|
166
|
+
}, 40)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const stepContext: StepContext = {
|
|
170
|
+
sessionId,
|
|
171
|
+
round,
|
|
172
|
+
history,
|
|
173
|
+
settings,
|
|
174
|
+
mode,
|
|
175
|
+
signal: controller.signal,
|
|
176
|
+
assistantId,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// agent/pre-step waterfall
|
|
180
|
+
await this.ctx.events.waterfall('agent/pre-step', stepContext, () => undefined)
|
|
181
|
+
|
|
182
|
+
let result: AdapterResult
|
|
183
|
+
let attempt = 0
|
|
184
|
+
for (;;) {
|
|
185
|
+
try {
|
|
186
|
+
result = await this.ctx.llm.stream({
|
|
187
|
+
settings,
|
|
188
|
+
model: findModel(settings.provider, activeModelId(settings)),
|
|
189
|
+
history,
|
|
190
|
+
system: this.ctx.systemPrompt.assemble(settings, mode),
|
|
191
|
+
mode,
|
|
192
|
+
signal: controller.signal,
|
|
193
|
+
handlers: {
|
|
194
|
+
onText: (delta) => {
|
|
195
|
+
streamedContent += delta
|
|
196
|
+
flush()
|
|
197
|
+
},
|
|
198
|
+
onReasoning: (delta) => {
|
|
199
|
+
streamedReasoning += delta
|
|
200
|
+
flush()
|
|
201
|
+
},
|
|
202
|
+
onToolCalls: () => undefined,
|
|
203
|
+
onUsage: (value) => {
|
|
204
|
+
usage = value
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
break
|
|
209
|
+
} catch (error) {
|
|
210
|
+
const stopped = isAbortError(error) || controller.signal.aborted
|
|
211
|
+
// Only retry when nothing reached the screen yet, so a retry never duplicates text.
|
|
212
|
+
const retryable =
|
|
213
|
+
!stopped &&
|
|
214
|
+
attempt < MAX_RETRIES &&
|
|
215
|
+
!streamedContent &&
|
|
216
|
+
!streamedReasoning &&
|
|
217
|
+
RETRYABLE.test(error instanceof Error ? error.message : '')
|
|
218
|
+
if (retryable) {
|
|
219
|
+
attempt += 1
|
|
220
|
+
window.dispatchEvent(
|
|
221
|
+
new CustomEvent('maxcode:notice', {
|
|
222
|
+
detail: {
|
|
223
|
+
message: `${providerName} is busy. Retrying (${attempt}/${MAX_RETRIES})…`,
|
|
224
|
+
tone: 'info',
|
|
225
|
+
},
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
await sleep(1200 * attempt * attempt, controller.signal)
|
|
229
|
+
if (controller.signal.aborted) continue
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
if (flushTimer !== undefined) window.clearTimeout(flushTimer)
|
|
233
|
+
turnError = stopped ? undefined : describeRequestError(error, providerName)
|
|
234
|
+
this.ctx.sessions.updateMessage(sessionId, assistantId, {
|
|
235
|
+
content: streamedContent,
|
|
236
|
+
reasoning: streamedReasoning,
|
|
237
|
+
stopped,
|
|
238
|
+
durationMs: Date.now() - startedAt,
|
|
239
|
+
error: turnError,
|
|
240
|
+
})
|
|
241
|
+
await this.ctx.events.waterfall('agent/request-error', error, stepContext, () => undefined)
|
|
242
|
+
break rounds
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (flushTimer !== undefined) window.clearTimeout(flushTimer)
|
|
247
|
+
this.ctx.sessions.updateMessage(sessionId, assistantId, {
|
|
248
|
+
content: result.content,
|
|
249
|
+
reasoning: result.reasoning,
|
|
250
|
+
thinkingBlocks: result.thinkingBlocks,
|
|
251
|
+
toolCalls: result.toolCalls,
|
|
252
|
+
usage: usage.input || usage.output ? usage : result.usage,
|
|
253
|
+
durationMs: Date.now() - startedAt,
|
|
254
|
+
stopped: controller.signal.aborted || undefined,
|
|
255
|
+
})
|
|
256
|
+
this.ctx.sessions.updateSession(sessionId, {
|
|
257
|
+
provider: settings.provider,
|
|
258
|
+
model: activeModelId(settings),
|
|
259
|
+
mode,
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
if (controller.signal.aborted) break
|
|
263
|
+
if (result.toolCalls.length === 0) {
|
|
264
|
+
const stepResult: StepResult = {
|
|
265
|
+
sessionId,
|
|
266
|
+
round,
|
|
267
|
+
assistant,
|
|
268
|
+
toolResults: [],
|
|
269
|
+
stopped: controller.signal.aborted,
|
|
270
|
+
}
|
|
271
|
+
this.ctx.events.emit('agent/step-finish', stepResult)
|
|
272
|
+
break
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const toolMessages: ToolMessage[] = []
|
|
276
|
+
|
|
277
|
+
for (const call of result.toolCalls) {
|
|
278
|
+
if (controller.signal.aborted) break
|
|
279
|
+
const toolSetting = settings.tools[call.name]
|
|
280
|
+
if (!toolSetting?.enabled) {
|
|
281
|
+
const message: ToolMessage = {
|
|
282
|
+
id: createId('tool'),
|
|
283
|
+
role: 'tool',
|
|
284
|
+
call,
|
|
285
|
+
status: 'error',
|
|
286
|
+
result: 'This tool is disabled in Settings → Tools.',
|
|
287
|
+
round,
|
|
288
|
+
createdAt: Date.now(),
|
|
289
|
+
}
|
|
290
|
+
this.ctx.sessions.addMessage(sessionId, message)
|
|
291
|
+
toolMessages.push(message)
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const toolMessageId = createId('tool')
|
|
296
|
+
const awaiting = toolSetting.approval === 'ask'
|
|
297
|
+
const toolMessage: ToolMessage = {
|
|
298
|
+
id: toolMessageId,
|
|
299
|
+
role: 'tool',
|
|
300
|
+
call,
|
|
301
|
+
status: awaiting ? 'awaiting' : 'running',
|
|
302
|
+
round,
|
|
303
|
+
createdAt: Date.now(),
|
|
304
|
+
}
|
|
305
|
+
this.ctx.sessions.addMessage(sessionId, toolMessage)
|
|
306
|
+
|
|
307
|
+
const toolStarted = Date.now()
|
|
308
|
+
try {
|
|
309
|
+
const output =
|
|
310
|
+
call.name === 'subagent'
|
|
311
|
+
? {
|
|
312
|
+
output: truncate(
|
|
313
|
+
await this.ctx.subagents.run(
|
|
314
|
+
typeof call.args.task === 'string' ? call.args.task : '',
|
|
315
|
+
typeof call.args.context === 'string' ? call.args.context : '',
|
|
316
|
+
controller.signal,
|
|
317
|
+
),
|
|
318
|
+
),
|
|
319
|
+
isError: false,
|
|
320
|
+
}
|
|
321
|
+
: await this.ctx.tools.execute(
|
|
322
|
+
call.name,
|
|
323
|
+
call.args,
|
|
324
|
+
settings,
|
|
325
|
+
mode,
|
|
326
|
+
controller.signal,
|
|
327
|
+
call.id,
|
|
328
|
+
userMessageId,
|
|
329
|
+
)
|
|
330
|
+
const finalToolMessage: ToolMessage = {
|
|
331
|
+
...toolMessage,
|
|
332
|
+
status: output.isError ? 'error' : 'done',
|
|
333
|
+
result: output.output,
|
|
334
|
+
durationMs: Date.now() - toolStarted,
|
|
335
|
+
}
|
|
336
|
+
this.ctx.sessions.updateMessage(sessionId, toolMessageId, finalToolMessage)
|
|
337
|
+
toolMessages.push(finalToolMessage)
|
|
338
|
+
} catch (error) {
|
|
339
|
+
const stopped = isAbortError(error)
|
|
340
|
+
const finalToolMessage: ToolMessage = {
|
|
341
|
+
...toolMessage,
|
|
342
|
+
status: 'error',
|
|
343
|
+
result: stopped ? 'Stopped.' : error instanceof Error ? error.message : 'The tool failed.',
|
|
344
|
+
durationMs: Date.now() - toolStarted,
|
|
345
|
+
}
|
|
346
|
+
this.ctx.sessions.updateMessage(sessionId, toolMessageId, finalToolMessage)
|
|
347
|
+
toolMessages.push(finalToolMessage)
|
|
348
|
+
if (stopped) break
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const stepResult: StepResult = {
|
|
353
|
+
sessionId,
|
|
354
|
+
round,
|
|
355
|
+
assistant,
|
|
356
|
+
toolResults: toolMessages,
|
|
357
|
+
stopped: controller.signal.aborted,
|
|
358
|
+
}
|
|
359
|
+
this.ctx.events.emit('agent/step-finish', stepResult)
|
|
360
|
+
|
|
361
|
+
if (controller.signal.aborted) {
|
|
362
|
+
const latest = this.ctx.sessions.current(sessionId)
|
|
363
|
+
if (latest) {
|
|
364
|
+
const recorded = new Set(
|
|
365
|
+
latest.messages
|
|
366
|
+
.filter((message): message is ToolMessage => message.role === 'tool')
|
|
367
|
+
.map((message) => message.call.id),
|
|
368
|
+
)
|
|
369
|
+
for (const call of result.toolCalls) {
|
|
370
|
+
if (recorded.has(call.id)) continue
|
|
371
|
+
this.ctx.sessions.addMessage(sessionId, {
|
|
372
|
+
id: createId('tool'),
|
|
373
|
+
role: 'tool',
|
|
374
|
+
call,
|
|
375
|
+
status: 'error',
|
|
376
|
+
result: 'Stopped before the tool ran.',
|
|
377
|
+
round,
|
|
378
|
+
createdAt: Date.now(),
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
break
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const finalSession = this.ctx.sessions.current(sessionId)
|
|
387
|
+
if (!controller.signal.aborted && finalSession) {
|
|
388
|
+
const last = finalSession.messages[finalSession.messages.length - 1]
|
|
389
|
+
if (last?.role === 'tool') {
|
|
390
|
+
const message: AssistantMessage = {
|
|
391
|
+
id: createId('msg'),
|
|
392
|
+
role: 'assistant',
|
|
393
|
+
content: '',
|
|
394
|
+
reasoning: '',
|
|
395
|
+
toolCalls: [],
|
|
396
|
+
round: MAX_ROUNDS,
|
|
397
|
+
createdAt: Date.now(),
|
|
398
|
+
error: `Stopped after ${MAX_ROUNDS} tool rounds. Send another message to continue.`,
|
|
399
|
+
}
|
|
400
|
+
this.ctx.sessions.addMessage(sessionId, message)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} finally {
|
|
404
|
+
this.controllers.delete(sessionId)
|
|
405
|
+
useRuntime.getState().setStreaming(sessionId, false)
|
|
406
|
+
const turnResult: TurnResult = {
|
|
407
|
+
sessionId,
|
|
408
|
+
userMessageId,
|
|
409
|
+
rounds: totalRounds,
|
|
410
|
+
durationMs: Date.now() - startedAt,
|
|
411
|
+
stopped: controller.signal.aborted,
|
|
412
|
+
error: turnError,
|
|
413
|
+
}
|
|
414
|
+
this.ctx.events.emit('agent/turn-finish', turnResult)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Service, type Context } from 'cordis'
|
|
2
|
+
import type { ToolExecutionContext, ToolPreExecuteResult } from '../types'
|
|
3
|
+
|
|
4
|
+
interface PendingApproval {
|
|
5
|
+
toolCallId: string
|
|
6
|
+
resolve: (allow: boolean) => void
|
|
7
|
+
cleanup: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const pendingApprovals = new Map<string, PendingApproval>()
|
|
11
|
+
|
|
12
|
+
export class ApprovalService extends Service {
|
|
13
|
+
static provide = 'approval'
|
|
14
|
+
static inject = ['tools']
|
|
15
|
+
|
|
16
|
+
constructor(ctx: Context) {
|
|
17
|
+
super(ctx, 'approval')
|
|
18
|
+
|
|
19
|
+
// Attach to tools/pre-execute waterfall gate
|
|
20
|
+
this.ctx.on('tools/pre-execute', async (exec: ToolExecutionContext, next: () => Promise<ToolPreExecuteResult>) => {
|
|
21
|
+
const toolSetting = exec.settings.tools[exec.name]
|
|
22
|
+
if (toolSetting?.approval === 'ask' && exec.callId) {
|
|
23
|
+
const approved = await this.waitForApproval(exec.callId, exec.signal)
|
|
24
|
+
if (!approved) {
|
|
25
|
+
return {
|
|
26
|
+
decision: 'deny',
|
|
27
|
+
reason: exec.signal.aborted ? 'Stopped before the tool ran.' : 'You denied this tool call.',
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return next()
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
waitForApproval(toolCallId: string, signal: AbortSignal): Promise<boolean> {
|
|
36
|
+
return waitForApproval(toolCallId, signal)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
resolveApproval(toolCallId: string, allow: boolean): void {
|
|
40
|
+
resolveApproval(toolCallId, allow)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function waitForApproval(toolCallId: string, signal: AbortSignal): Promise<boolean> {
|
|
45
|
+
if (signal.aborted) return Promise.resolve(false)
|
|
46
|
+
return new Promise<boolean>((resolve) => {
|
|
47
|
+
const finish = (allow: boolean) => {
|
|
48
|
+
const entry = pendingApprovals.get(toolCallId)
|
|
49
|
+
if (!entry) return
|
|
50
|
+
entry.cleanup()
|
|
51
|
+
pendingApprovals.delete(toolCallId)
|
|
52
|
+
resolve(allow)
|
|
53
|
+
}
|
|
54
|
+
const onAbort = () => finish(false)
|
|
55
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
56
|
+
pendingApprovals.set(toolCallId, {
|
|
57
|
+
toolCallId,
|
|
58
|
+
resolve: finish,
|
|
59
|
+
cleanup: () => signal.removeEventListener('abort', onAbort),
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveApproval(toolCallId: string, allow: boolean): void {
|
|
65
|
+
pendingApprovals.get(toolCallId)?.resolve(allow)
|
|
66
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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'
|
|
5
|
+
import { activeModelId, useSettings } from '../../store/settings'
|
|
6
|
+
import { useChats } from '../../store/chats'
|
|
7
|
+
|
|
8
|
+
export class CompactionService extends Service {
|
|
9
|
+
static provide = 'compaction'
|
|
10
|
+
static inject = ['sessions', 'llm']
|
|
11
|
+
|
|
12
|
+
constructor(ctx: Context) {
|
|
13
|
+
super(ctx, 'compaction')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
isCompacting(sessionId: string): boolean {
|
|
17
|
+
return isCompacting(sessionId)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async compact(sessionId: string): Promise<boolean> {
|
|
21
|
+
return await compactSession(sessionId)
|
|
22
|
+
}
|
|
23
|
+
|
|
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
|
|
27
|
+
|
|
28
|
+
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',
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
return await this.compact(sessionId)
|
|
42
|
+
}
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Service, type Context } from 'cordis'
|
|
2
|
+
import type { AdapterResult } from '../../agent/adapters/shared'
|
|
3
|
+
import { streamAnthropic } from '../../agent/adapters/anthropic'
|
|
4
|
+
import { streamGemini } from '../../agent/adapters/gemini'
|
|
5
|
+
import { streamOpenAI } from '../../agent/adapters/openai'
|
|
6
|
+
import { streamOpenAIResponses } from '../../agent/adapters/openai-responses'
|
|
7
|
+
import { apiKindFor } from '../../store/models'
|
|
8
|
+
import type { ModelRequest } from '../types'
|
|
9
|
+
|
|
10
|
+
export class LlmService extends Service {
|
|
11
|
+
static provide = 'llm'
|
|
12
|
+
|
|
13
|
+
constructor(ctx: Context) {
|
|
14
|
+
super(ctx, 'llm')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async stream(request: ModelRequest): Promise<AdapterResult> {
|
|
18
|
+
return await this.ctx.events.waterfall('agent/request', request, async () => {
|
|
19
|
+
const kind = apiKindFor(request.settings.provider, request.settings.model, request.model)
|
|
20
|
+
switch (kind) {
|
|
21
|
+
case 'anthropic':
|
|
22
|
+
return await streamAnthropic(request)
|
|
23
|
+
case 'gemini':
|
|
24
|
+
return await streamGemini(request)
|
|
25
|
+
case 'openai-responses':
|
|
26
|
+
return await streamOpenAIResponses(request)
|
|
27
|
+
default:
|
|
28
|
+
return await streamOpenAI(request)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Service, type Context } from 'cordis'
|
|
2
|
+
import type { ChatSession, Message } from '../../types'
|
|
3
|
+
import { useChats } from '../../store/chats'
|
|
4
|
+
import { turnHistory as extractTurnHistory } from '../../agent/queue'
|
|
5
|
+
|
|
6
|
+
export class SessionService extends Service {
|
|
7
|
+
static provide = 'sessions'
|
|
8
|
+
|
|
9
|
+
constructor(ctx: Context) {
|
|
10
|
+
super(ctx, 'sessions')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
current(sessionId: string): ChatSession | undefined {
|
|
14
|
+
return useChats.getState().sessions.find((s) => s.id === sessionId)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
all(): ChatSession[] {
|
|
18
|
+
return useChats.getState().sessions
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getMessages(sessionId: string): Message[] {
|
|
22
|
+
return this.current(sessionId)?.messages ?? []
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
addMessage(sessionId: string, message: Message): void {
|
|
26
|
+
useChats.getState().addMessage(sessionId, message)
|
|
27
|
+
void this.flush(sessionId)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
updateMessage(sessionId: string, messageId: string, patch: Partial<Message>): void {
|
|
31
|
+
useChats.getState().updateMessage(sessionId, messageId, patch)
|
|
32
|
+
void this.flush(sessionId)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
updateSession(sessionId: string, patch: Partial<ChatSession>): void {
|
|
36
|
+
useChats.getState().updateSession(sessionId, patch)
|
|
37
|
+
void this.flush(sessionId)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
turnHistory(messages: Message[], userMessageId: string): Message[] | null {
|
|
41
|
+
return extractTurnHistory(messages, userMessageId)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async flush(sessionId: string): Promise<void> {
|
|
45
|
+
await this.ctx.events.parallel('session/flush', sessionId).catch(() => undefined)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Service, type Context } from 'cordis'
|
|
2
|
+
import { runSubagent } from '../../agent/subagent'
|
|
3
|
+
|
|
4
|
+
export class SubagentService extends Service {
|
|
5
|
+
static provide = 'subagents'
|
|
6
|
+
static inject = ['tools', 'llm', 'systemPrompt']
|
|
7
|
+
|
|
8
|
+
constructor(ctx: Context) {
|
|
9
|
+
super(ctx, 'subagents')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async run(task: string, context: string, signal: AbortSignal): Promise<string> {
|
|
13
|
+
return await runSubagent(task, context, signal)
|
|
14
|
+
}
|
|
15
|
+
}
|