@miphamai/cli 0.6.9 → 0.7.1
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/package.json +1 -1
- package/src/core/engine.ts +73 -10
- package/src/index.tsx +6 -0
- package/src/mcp/client.ts +8 -2
- package/src/plugin/plugin-loader.ts +28 -2
- package/src/plugin/plugin-manager.ts +21 -0
- package/src/providers/anthropic.ts +25 -1
- package/src/providers/openai-compat.ts +24 -14
- package/src/shared/types.ts +13 -2
- package/src/tools/exec/bash.ts +37 -1
- package/src/tools/file/edit.ts +17 -0
- package/src/tools/file/read.ts +9 -0
- package/src/tools/file/write.ts +10 -0
package/package.json
CHANGED
package/src/core/engine.ts
CHANGED
|
@@ -148,6 +148,7 @@ export class QueryEngine {
|
|
|
148
148
|
|
|
149
149
|
let assistantContent = ''
|
|
150
150
|
let reasoningContent = ''
|
|
151
|
+
let thinkingContent = ''
|
|
151
152
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
152
153
|
|
|
153
154
|
// Stream model response
|
|
@@ -174,6 +175,10 @@ export class QueryEngine {
|
|
|
174
175
|
reasoningContent += chunk.reasoning_content
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
if (chunk.type === 'thinking' && chunk.thinking) {
|
|
179
|
+
thinkingContent += chunk.thinking
|
|
180
|
+
}
|
|
181
|
+
|
|
177
182
|
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
178
183
|
toolUses.push({
|
|
179
184
|
id: chunk.toolUse.id,
|
|
@@ -184,10 +189,17 @@ export class QueryEngine {
|
|
|
184
189
|
|
|
185
190
|
if (chunk.type === 'stop') {
|
|
186
191
|
// Add assistant response to context
|
|
187
|
-
if (assistantContent || reasoningContent) {
|
|
192
|
+
if (assistantContent || reasoningContent || thinkingContent) {
|
|
193
|
+
const contentBlocks: import('../shared/types').ContentBlock[] = []
|
|
194
|
+
if (thinkingContent) {
|
|
195
|
+
contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
|
|
196
|
+
}
|
|
197
|
+
if (assistantContent) {
|
|
198
|
+
contentBlocks.push({ type: 'text', text: assistantContent })
|
|
199
|
+
}
|
|
188
200
|
const msg: import('../shared/types').Message = {
|
|
189
201
|
role: 'assistant',
|
|
190
|
-
content: assistantContent || '',
|
|
202
|
+
content: thinkingContent ? contentBlocks : assistantContent || '',
|
|
191
203
|
}
|
|
192
204
|
if (reasoningContent) msg.reasoning_content = reasoningContent
|
|
193
205
|
this.context.addMessage(msg)
|
|
@@ -197,10 +209,17 @@ export class QueryEngine {
|
|
|
197
209
|
} catch (err) {
|
|
198
210
|
if (isAbortError(err)) {
|
|
199
211
|
// User interrupted — keep partial content, stop gracefully
|
|
200
|
-
if (assistantContent || reasoningContent) {
|
|
212
|
+
if (assistantContent || reasoningContent || thinkingContent) {
|
|
213
|
+
const contentBlocks: import('../shared/types').ContentBlock[] = []
|
|
214
|
+
if (thinkingContent) {
|
|
215
|
+
contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
|
|
216
|
+
}
|
|
217
|
+
if (assistantContent) {
|
|
218
|
+
contentBlocks.push({ type: 'text', text: assistantContent })
|
|
219
|
+
}
|
|
201
220
|
const msg: import('../shared/types').Message = {
|
|
202
221
|
role: 'assistant',
|
|
203
|
-
content: assistantContent || '',
|
|
222
|
+
content: thinkingContent ? contentBlocks : assistantContent || '',
|
|
204
223
|
}
|
|
205
224
|
if (reasoningContent) msg.reasoning_content = reasoningContent
|
|
206
225
|
this.context.addMessage(msg)
|
|
@@ -236,13 +255,21 @@ export class QueryEngine {
|
|
|
236
255
|
}
|
|
237
256
|
|
|
238
257
|
// Add tool use + result to context
|
|
258
|
+
// DeepSeek V4 thinking mode requires reasoning_content on every assistant message
|
|
239
259
|
this.context.addMessage({
|
|
240
260
|
role: 'assistant',
|
|
241
261
|
content: [{ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.input }],
|
|
262
|
+
reasoning_content: '',
|
|
242
263
|
})
|
|
243
264
|
this.context.addMessage({
|
|
244
265
|
role: 'user',
|
|
245
|
-
content: [
|
|
266
|
+
content: [
|
|
267
|
+
{
|
|
268
|
+
type: 'tool_result',
|
|
269
|
+
tool_use_id: toolUse.id,
|
|
270
|
+
content: result.success ? result.content : result.error || result.content,
|
|
271
|
+
},
|
|
272
|
+
],
|
|
246
273
|
})
|
|
247
274
|
}
|
|
248
275
|
|
|
@@ -282,6 +309,7 @@ export class QueryEngine {
|
|
|
282
309
|
|
|
283
310
|
let assistantContent = ''
|
|
284
311
|
let reasoningContent = ''
|
|
312
|
+
let thinkingContent = ''
|
|
285
313
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
286
314
|
|
|
287
315
|
try {
|
|
@@ -301,6 +329,10 @@ export class QueryEngine {
|
|
|
301
329
|
reasoningContent += chunk.reasoning_content
|
|
302
330
|
}
|
|
303
331
|
|
|
332
|
+
if (chunk.type === 'thinking' && chunk.thinking) {
|
|
333
|
+
thinkingContent += chunk.thinking
|
|
334
|
+
}
|
|
335
|
+
|
|
304
336
|
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
305
337
|
toolUses.push({
|
|
306
338
|
id: chunk.toolUse.id,
|
|
@@ -311,10 +343,17 @@ export class QueryEngine {
|
|
|
311
343
|
}
|
|
312
344
|
} catch (err) {
|
|
313
345
|
if (isAbortError(err)) {
|
|
314
|
-
if (assistantContent || reasoningContent) {
|
|
346
|
+
if (assistantContent || reasoningContent || thinkingContent) {
|
|
347
|
+
const contentBlocks: import('../shared/types').ContentBlock[] = []
|
|
348
|
+
if (thinkingContent) {
|
|
349
|
+
contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
|
|
350
|
+
}
|
|
351
|
+
if (assistantContent) {
|
|
352
|
+
contentBlocks.push({ type: 'text', text: assistantContent })
|
|
353
|
+
}
|
|
315
354
|
const msg: import('../shared/types').Message = {
|
|
316
355
|
role: 'assistant',
|
|
317
|
-
content: assistantContent || '',
|
|
356
|
+
content: thinkingContent ? contentBlocks : assistantContent || '',
|
|
318
357
|
}
|
|
319
358
|
if (reasoningContent) msg.reasoning_content = reasoningContent
|
|
320
359
|
this.context.addMessage(msg)
|
|
@@ -326,15 +365,31 @@ export class QueryEngine {
|
|
|
326
365
|
return
|
|
327
366
|
}
|
|
328
367
|
|
|
329
|
-
if (assistantContent || reasoningContent) {
|
|
368
|
+
if (assistantContent || reasoningContent || thinkingContent) {
|
|
369
|
+
const contentBlocks: import('../shared/types').ContentBlock[] = []
|
|
370
|
+
if (thinkingContent) {
|
|
371
|
+
contentBlocks.push({ type: 'thinking', thinking: thinkingContent })
|
|
372
|
+
}
|
|
373
|
+
if (assistantContent) {
|
|
374
|
+
contentBlocks.push({ type: 'text', text: assistantContent })
|
|
375
|
+
}
|
|
330
376
|
const msg: import('../shared/types').Message = {
|
|
331
377
|
role: 'assistant',
|
|
332
|
-
content: assistantContent || '',
|
|
378
|
+
content: thinkingContent ? contentBlocks : assistantContent || '',
|
|
333
379
|
}
|
|
334
380
|
if (reasoningContent) msg.reasoning_content = reasoningContent
|
|
335
381
|
this.context.addMessage(msg)
|
|
336
382
|
}
|
|
337
383
|
|
|
384
|
+
// Safety: prevent silent truncation when max turns reached
|
|
385
|
+
if (turn === MAX_TURNS - 1 && toolUses.length > 0) {
|
|
386
|
+
yield {
|
|
387
|
+
type: 'error',
|
|
388
|
+
error: `Max tool-calling turns (${MAX_TURNS}) reached. Some tool calls were not executed.`,
|
|
389
|
+
}
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
338
393
|
// No more tool calls — fire Stop hook and potentially continue
|
|
339
394
|
if (toolUses.length === 0) {
|
|
340
395
|
yield* this.checkStopHook(signal)
|
|
@@ -350,13 +405,21 @@ export class QueryEngine {
|
|
|
350
405
|
content: result.content,
|
|
351
406
|
}
|
|
352
407
|
|
|
408
|
+
// DeepSeek V4 thinking mode requires reasoning_content on every assistant message
|
|
353
409
|
this.context.addMessage({
|
|
354
410
|
role: 'assistant',
|
|
355
411
|
content: [{ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.input }],
|
|
412
|
+
reasoning_content: '',
|
|
356
413
|
})
|
|
357
414
|
this.context.addMessage({
|
|
358
415
|
role: 'user',
|
|
359
|
-
content: [
|
|
416
|
+
content: [
|
|
417
|
+
{
|
|
418
|
+
type: 'tool_result',
|
|
419
|
+
tool_use_id: toolUse.id,
|
|
420
|
+
content: result.success ? result.content : result.error || result.content,
|
|
421
|
+
},
|
|
422
|
+
],
|
|
360
423
|
})
|
|
361
424
|
}
|
|
362
425
|
}
|
package/src/index.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { loadSessionMemories } from './core/memory/memory-loader'
|
|
|
8
8
|
import { ContextManager } from './core/context'
|
|
9
9
|
import { QueryEngine } from './core/engine'
|
|
10
10
|
import { SessionStore } from './core/session-store'
|
|
11
|
+
import type { PermissionLevel } from './shared/types'
|
|
11
12
|
import { SkillsLoader } from './skills/loader'
|
|
12
13
|
import { PluginManager } from './plugin/plugin-manager'
|
|
13
14
|
import { loadPlugins } from './plugin/plugin-loader'
|
|
@@ -148,6 +149,11 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
148
149
|
engine.setAgentViewManager(agentViewManager)
|
|
149
150
|
engine.setSkillsLoader(skillsLoader)
|
|
150
151
|
|
|
152
|
+
// Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
|
|
153
|
+
if (config.permission) {
|
|
154
|
+
engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
|
|
155
|
+
}
|
|
156
|
+
|
|
151
157
|
// Initialize agent registry and load plugin agents/skills/MCP/hooks
|
|
152
158
|
const agentRegistry = new AgentRegistry()
|
|
153
159
|
agentRegistry.loadUserAgents()
|
package/src/mcp/client.ts
CHANGED
|
@@ -85,16 +85,22 @@ export class McpClient {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Disconnect an MCP server and return the names of its registered tools
|
|
90
|
+
* so the caller can unregister them from the central tool registry.
|
|
91
|
+
*/
|
|
92
|
+
disconnect(name: string): string[] {
|
|
89
93
|
const conn = this.connections.get(name)
|
|
90
|
-
if (!conn) return
|
|
94
|
+
if (!conn) return []
|
|
91
95
|
|
|
92
96
|
try {
|
|
93
97
|
conn.transport.close()
|
|
94
98
|
} catch {
|
|
95
99
|
/* best effort */
|
|
96
100
|
}
|
|
101
|
+
const toolNames = conn.tools.map((t) => t.name)
|
|
97
102
|
this.connections.delete(name)
|
|
103
|
+
return toolNames
|
|
98
104
|
}
|
|
99
105
|
|
|
100
106
|
async closeAll(): Promise<void> {
|
|
@@ -24,6 +24,9 @@ export function loadPlugins(
|
|
|
24
24
|
toolsMap: Map<string, ToolDefinition>,
|
|
25
25
|
): void {
|
|
26
26
|
for (const plugin of pluginManager.getEnabled()) {
|
|
27
|
+
const mcpServers: string[] = []
|
|
28
|
+
const hookEvents: HookEvent[] = []
|
|
29
|
+
|
|
27
30
|
// ── Custom agents ──
|
|
28
31
|
const agentsDir = join(plugin.path, 'agents')
|
|
29
32
|
if (existsSync(agentsDir)) {
|
|
@@ -59,6 +62,7 @@ export function loadPlugins(
|
|
|
59
62
|
const raw = readFileSync(join(mcpDir, entry), 'utf-8')
|
|
60
63
|
const cfg = JSON.parse(raw) as McpServerConfig
|
|
61
64
|
if (cfg.name && cfg.command) {
|
|
65
|
+
mcpServers.push(cfg.name)
|
|
62
66
|
mcpClient
|
|
63
67
|
.connect(cfg)
|
|
64
68
|
.then(() => {
|
|
@@ -87,8 +91,6 @@ export function loadPlugins(
|
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
// ── Hooks from plugin.json ──
|
|
90
|
-
// Plugin hooks are HookConfig objects (type + command/url), not HookDefinition
|
|
91
|
-
// (which requires a handler function). Convert them via executeHook wrapper.
|
|
92
94
|
try {
|
|
93
95
|
const manifestPath = join(plugin.path, 'plugin.json')
|
|
94
96
|
if (existsSync(manifestPath)) {
|
|
@@ -100,6 +102,7 @@ export function loadPlugins(
|
|
|
100
102
|
| HookEvent
|
|
101
103
|
| undefined
|
|
102
104
|
if (event) {
|
|
105
|
+
hookEvents.push(event)
|
|
103
106
|
hookEngine.register({
|
|
104
107
|
event,
|
|
105
108
|
handler: async (ctx) => executeHook(hookCfg, ctx),
|
|
@@ -112,5 +115,28 @@ export function loadPlugins(
|
|
|
112
115
|
} catch (err) {
|
|
113
116
|
process.stderr.write(`[plugin] Failed to load hooks from "${plugin.name}": ${String(err)}\n`)
|
|
114
117
|
}
|
|
118
|
+
|
|
119
|
+
// ── Register cleanup callback ──
|
|
120
|
+
pluginManager.onRemove(plugin.name, () => {
|
|
121
|
+
// Disconnect MCP servers and unregister their tools
|
|
122
|
+
for (const serverName of mcpServers) {
|
|
123
|
+
try {
|
|
124
|
+
const toolNames = mcpClient.disconnect(serverName)
|
|
125
|
+
for (const toolName of toolNames) {
|
|
126
|
+
toolsMap.delete(`mcp__${serverName}__${toolName}`)
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
/* best effort */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Unregister hooks
|
|
133
|
+
for (const event of hookEvents) {
|
|
134
|
+
try {
|
|
135
|
+
hookEngine.unregister(event)
|
|
136
|
+
} catch {
|
|
137
|
+
/* best effort */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
})
|
|
115
141
|
}
|
|
116
142
|
}
|
|
@@ -18,6 +18,7 @@ export class PluginManager {
|
|
|
18
18
|
private plugins: InstalledPlugin[] = []
|
|
19
19
|
private pluginDir: string
|
|
20
20
|
private statePath: string
|
|
21
|
+
private cleanupCallbacks = new Map<string, () => void>()
|
|
21
22
|
|
|
22
23
|
constructor(pluginDir?: string) {
|
|
23
24
|
this.pluginDir = pluginDir ?? PLUGIN_DIR
|
|
@@ -139,9 +140,29 @@ export class PluginManager {
|
|
|
139
140
|
return [...this.plugins]
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Register a cleanup callback that will be invoked when the named plugin is removed.
|
|
145
|
+
* This allows the plugin loader to tear down hooks, agents, MCP connections, and tools.
|
|
146
|
+
*/
|
|
147
|
+
onRemove(name: string, cleanup: () => void): void {
|
|
148
|
+
this.cleanupCallbacks.set(name, cleanup)
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
remove(name: string): boolean {
|
|
143
152
|
const plugin = this.plugins.find((p) => p.name === name)
|
|
144
153
|
if (!plugin) return false
|
|
154
|
+
|
|
155
|
+
// Run registered cleanup: disconnect MCP, unregister hooks/agents/tools
|
|
156
|
+
const cleanup = this.cleanupCallbacks.get(name)
|
|
157
|
+
if (cleanup) {
|
|
158
|
+
try {
|
|
159
|
+
cleanup()
|
|
160
|
+
} catch {
|
|
161
|
+
/* cleanup is best-effort */
|
|
162
|
+
}
|
|
163
|
+
this.cleanupCallbacks.delete(name)
|
|
164
|
+
}
|
|
165
|
+
|
|
145
166
|
try {
|
|
146
167
|
rmSync(plugin.path, { recursive: true, force: true })
|
|
147
168
|
} catch {
|
|
@@ -11,6 +11,7 @@ import { fetchWithRetry } from './fetch-utils'
|
|
|
11
11
|
interface AnthropicContentBlock {
|
|
12
12
|
type: string
|
|
13
13
|
text?: string
|
|
14
|
+
thinking?: string
|
|
14
15
|
id?: string
|
|
15
16
|
name?: string
|
|
16
17
|
input?: Record<string, unknown>
|
|
@@ -30,6 +31,7 @@ interface AnthropicSSEEvent {
|
|
|
30
31
|
delta?: {
|
|
31
32
|
type: string
|
|
32
33
|
text?: string
|
|
34
|
+
thinking?: string
|
|
33
35
|
partial_json?: string
|
|
34
36
|
}
|
|
35
37
|
error?: { type: string; message: string }
|
|
@@ -136,6 +138,10 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
136
138
|
yield { type: 'text', content: delta.text }
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
if (delta.type === 'thinking_delta' && delta.text) {
|
|
142
|
+
yield { type: 'thinking', thinking: delta.text }
|
|
143
|
+
}
|
|
144
|
+
|
|
139
145
|
if (delta.type === 'input_json_delta' && delta.partial_json) {
|
|
140
146
|
accumulatedToolInput += delta.partial_json
|
|
141
147
|
}
|
|
@@ -215,9 +221,16 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
215
221
|
if (msg.role === 'system') continue
|
|
216
222
|
|
|
217
223
|
if (typeof msg.content === 'string') {
|
|
224
|
+
const content: unknown[] = [{ type: 'text', text: msg.content }]
|
|
225
|
+
// DeepSeek V4 thinking mode via Anthropic endpoint: every assistant
|
|
226
|
+
// message must contain a thinking block if any message in history does.
|
|
227
|
+
if (msg.role === 'assistant') {
|
|
228
|
+
const thinkingText = (msg as any).reasoning_content || ''
|
|
229
|
+
content.unshift({ type: 'thinking', thinking: thinkingText })
|
|
230
|
+
}
|
|
218
231
|
result.push({
|
|
219
232
|
role: msg.role,
|
|
220
|
-
content
|
|
233
|
+
content,
|
|
221
234
|
})
|
|
222
235
|
} else {
|
|
223
236
|
const blocks = (msg.content as ContentBlock[]).map((block) => {
|
|
@@ -250,6 +263,12 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
250
263
|
}
|
|
251
264
|
}
|
|
252
265
|
|
|
266
|
+
case 'thinking':
|
|
267
|
+
return {
|
|
268
|
+
type: 'thinking',
|
|
269
|
+
thinking: block.thinking,
|
|
270
|
+
}
|
|
271
|
+
|
|
253
272
|
case 'tool_use':
|
|
254
273
|
return {
|
|
255
274
|
type: 'tool_use',
|
|
@@ -270,6 +289,11 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
270
289
|
}
|
|
271
290
|
})
|
|
272
291
|
|
|
292
|
+
// DeepSeek V4 thinking mode via Anthropic endpoint: every assistant
|
|
293
|
+
// message must contain a thinking block if any message in history does.
|
|
294
|
+
if (msg.role === 'assistant' && !blocks.some((b: any) => b.type === 'thinking')) {
|
|
295
|
+
blocks.unshift({ type: 'thinking', thinking: '' })
|
|
296
|
+
}
|
|
273
297
|
result.push({ role: msg.role, content: blocks })
|
|
274
298
|
}
|
|
275
299
|
}
|
|
@@ -172,7 +172,7 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
172
172
|
const next: Message | undefined = messages[i + 1]
|
|
173
173
|
if (
|
|
174
174
|
msg.role === 'assistant' &&
|
|
175
|
-
msg.content.length > 0 &&
|
|
175
|
+
(msg.content.length > 0 || msg.reasoning_content) &&
|
|
176
176
|
next &&
|
|
177
177
|
next.role === 'assistant' &&
|
|
178
178
|
typeof next.content !== 'string' &&
|
|
@@ -190,9 +190,8 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
190
190
|
arguments: JSON.stringify(tu.input),
|
|
191
191
|
},
|
|
192
192
|
})),
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
combinedMsg.reasoning_content = msg.reasoning_content
|
|
193
|
+
// DeepSeek V4 thinking mode: every assistant message needs reasoning_content
|
|
194
|
+
reasoning_content: msg.reasoning_content || '',
|
|
196
195
|
}
|
|
197
196
|
result.push(combinedMsg)
|
|
198
197
|
i++ // skip the next message (consumed)
|
|
@@ -200,8 +199,9 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
200
199
|
}
|
|
201
200
|
|
|
202
201
|
const standaloneMsg: Record<string, unknown> = { role: msg.role, content: msg.content }
|
|
203
|
-
|
|
204
|
-
|
|
202
|
+
// DeepSeek V4 thinking mode: every assistant message needs reasoning_content
|
|
203
|
+
if (msg.role === 'assistant') {
|
|
204
|
+
standaloneMsg.reasoning_content = msg.reasoning_content || ''
|
|
205
205
|
}
|
|
206
206
|
result.push(standaloneMsg)
|
|
207
207
|
continue
|
|
@@ -214,11 +214,12 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
214
214
|
|
|
215
215
|
// ── Assistant tool_use → OpenAI tool_calls ──
|
|
216
216
|
if (toolUses.length > 0 && msg.role === 'assistant') {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
const textParts: string[] = []
|
|
218
|
+
for (const b of blocks) {
|
|
219
|
+
if (b.type === 'text') textParts.push(b.text)
|
|
220
|
+
if (b.type === 'thinking') textParts.push(`[Thinking: ${b.thinking}]`)
|
|
221
|
+
}
|
|
222
|
+
const textContent = textParts.join('') || null
|
|
222
223
|
|
|
223
224
|
const toolUseMsg: Record<string, unknown> = {
|
|
224
225
|
role: 'assistant',
|
|
@@ -231,9 +232,8 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
231
232
|
arguments: JSON.stringify(tu.input),
|
|
232
233
|
},
|
|
233
234
|
})),
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
toolUseMsg.reasoning_content = msg.reasoning_content
|
|
235
|
+
// DeepSeek V4 thinking mode: every assistant message needs reasoning_content
|
|
236
|
+
reasoning_content: msg.reasoning_content || '',
|
|
237
237
|
}
|
|
238
238
|
result.push(toolUseMsg)
|
|
239
239
|
continue
|
|
@@ -241,6 +241,16 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
241
241
|
|
|
242
242
|
// ── Tool result → OpenAI tool role ──
|
|
243
243
|
if (toolResults.length > 0) {
|
|
244
|
+
// Emit companion text blocks as a user message before tool results
|
|
245
|
+
const textContent =
|
|
246
|
+
blocks
|
|
247
|
+
.filter((b) => b.type === 'text')
|
|
248
|
+
.map((b) => (b.type === 'text' ? b.text : ''))
|
|
249
|
+
.join('') || null
|
|
250
|
+
if (textContent) {
|
|
251
|
+
result.push({ role: 'user', content: textContent })
|
|
252
|
+
}
|
|
253
|
+
|
|
244
254
|
for (const tr of toolResults) {
|
|
245
255
|
result.push({
|
|
246
256
|
role: 'tool',
|
package/src/shared/types.ts
CHANGED
|
@@ -41,7 +41,16 @@ export interface ToolResultContent {
|
|
|
41
41
|
tool_use_id: string
|
|
42
42
|
content: string
|
|
43
43
|
}
|
|
44
|
-
export
|
|
44
|
+
export interface ThinkingContent {
|
|
45
|
+
type: 'thinking'
|
|
46
|
+
thinking: string
|
|
47
|
+
}
|
|
48
|
+
export type ContentBlock =
|
|
49
|
+
| TextContent
|
|
50
|
+
| ImageContent
|
|
51
|
+
| ToolUseContent
|
|
52
|
+
| ToolResultContent
|
|
53
|
+
| ThinkingContent
|
|
45
54
|
|
|
46
55
|
export interface Message {
|
|
47
56
|
role: 'system' | 'user' | 'assistant'
|
|
@@ -102,13 +111,15 @@ export interface ToolDefinition {
|
|
|
102
111
|
|
|
103
112
|
// ── Stream Types ──
|
|
104
113
|
export interface StreamChunk {
|
|
105
|
-
type: 'text' | 'tool_use' | 'tool_result' | 'stop' | 'error'
|
|
114
|
+
type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stop' | 'error'
|
|
106
115
|
content?: string
|
|
107
116
|
toolUse?: ToolUseContent
|
|
108
117
|
tool_use_id?: string
|
|
109
118
|
error?: string
|
|
110
119
|
/** DeepSeek reasoning tokens accumulated during this stream. */
|
|
111
120
|
reasoning_content?: string
|
|
121
|
+
/** Anthropic thinking block content (DeepSeek Anthropic endpoint). */
|
|
122
|
+
thinking?: string
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
// ── Config Types ──
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -5,17 +5,46 @@ const BLOCKED_PATTERNS = [
|
|
|
5
5
|
// Recursive root deletion without preserve-root safeguard
|
|
6
6
|
/\brm\s+-rf\s+\/(\s|$)/,
|
|
7
7
|
/\brm\s+-rf\s+\/\*\s*$/,
|
|
8
|
+
/\bsudo\s+rm\s+.*\//,
|
|
8
9
|
// Filesystem manipulation
|
|
9
10
|
/\bmkfs\./,
|
|
10
11
|
/\bdd\s+if=/,
|
|
11
12
|
// Fork bomb
|
|
12
13
|
/:\s*\(\s*\)\s*\{\s*:\s*\|/,
|
|
13
14
|
// Recursive root chmod
|
|
14
|
-
/\bchmod\s+.*777\s+\//,
|
|
15
|
+
/\bchmod\s+.*(?:777|o\+w|a\+w)\s+\//,
|
|
15
16
|
// Direct block device write
|
|
16
17
|
/>\s*\/dev\/sd[a-z]/,
|
|
17
18
|
// SSH private key theft
|
|
18
19
|
/\bcat\s+.*\/\.ssh\/id_/,
|
|
20
|
+
// Shell command substitution (commonly used for obfuscation/evasion)
|
|
21
|
+
/\$\(/,
|
|
22
|
+
// Backtick command substitution
|
|
23
|
+
/`[^`]+`/,
|
|
24
|
+
// Interpreter code execution (bypass vector)
|
|
25
|
+
/\bpython3?\s+-c\b/,
|
|
26
|
+
/\bpython3?\s+-m\b/,
|
|
27
|
+
/\bperl\s+-[ep]\b/,
|
|
28
|
+
/\bruby\s+-e\b/,
|
|
29
|
+
/\bnode\s+-e\b/,
|
|
30
|
+
// Reverse shell patterns
|
|
31
|
+
/\bnc\s+.*-e\b/,
|
|
32
|
+
/\bncat\s+.*-e\b/,
|
|
33
|
+
/\bexec\s+\d+<>/,
|
|
34
|
+
// Download + pipe to interpreter
|
|
35
|
+
/\bcurl\s+.*\|\s*(?:ba)?sh\b/,
|
|
36
|
+
/\bwget\s+.*\|\s*(?:ba)?sh\b/,
|
|
37
|
+
/\bcurl\s+.*\|\s*python/,
|
|
38
|
+
/\bwget\s+.*\|\s*python/,
|
|
39
|
+
// Download + execute
|
|
40
|
+
/\bcurl\s+.*-O\s+\/tmp\/.*\s*&&/,
|
|
41
|
+
/\bwget\s+.*-O\s+\/tmp\/.*\s*&&/,
|
|
42
|
+
// Data exfiltration via curl file://
|
|
43
|
+
/\bcurl\s+file:\/\//,
|
|
44
|
+
// SCP exfiltration of sensitive files
|
|
45
|
+
/\bscp\s+.*(?:\.ssh|\.aws|\.env)/,
|
|
46
|
+
// Write to system paths
|
|
47
|
+
/>\s*\/(?:etc|usr|boot|sys|proc)\//,
|
|
19
48
|
]
|
|
20
49
|
|
|
21
50
|
const BLOCKED_COMMANDS = [
|
|
@@ -28,6 +57,13 @@ const BLOCKED_COMMANDS = [
|
|
|
28
57
|
'mkfs.fat',
|
|
29
58
|
'mkfs.vfat',
|
|
30
59
|
'mkswap',
|
|
60
|
+
'shutdown',
|
|
61
|
+
'reboot',
|
|
62
|
+
'halt',
|
|
63
|
+
'poweroff',
|
|
64
|
+
'init',
|
|
65
|
+
'telinit',
|
|
66
|
+
'systemctl',
|
|
31
67
|
]
|
|
32
68
|
|
|
33
69
|
function isBlocked(command: string): string | null {
|
package/src/tools/file/edit.ts
CHANGED
|
@@ -24,7 +24,24 @@ export const editTool: ToolDefinition = {
|
|
|
24
24
|
const newStr = params.new_string as string
|
|
25
25
|
const replaceAll = params.replace_all as boolean
|
|
26
26
|
|
|
27
|
+
const MAX_FILE_SIZE = 10_000_000 // 10 MB
|
|
28
|
+
const MAX_STRING_SIZE = 5_000_000 // 5 MB
|
|
29
|
+
if (oldStr.length > MAX_STRING_SIZE || newStr.length > MAX_STRING_SIZE) {
|
|
30
|
+
return {
|
|
31
|
+
success: false,
|
|
32
|
+
content: '',
|
|
33
|
+
error: `String too large. Max: 5 MB per string.`,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
const content = readFileSync(filePath, 'utf-8')
|
|
38
|
+
if (content.length > MAX_FILE_SIZE) {
|
|
39
|
+
return {
|
|
40
|
+
success: false,
|
|
41
|
+
content: '',
|
|
42
|
+
error: `File too large (${(content.length / 1e6).toFixed(1)} MB). Max: 10 MB.`,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
28
45
|
|
|
29
46
|
if (replaceAll) {
|
|
30
47
|
if (!content.includes(oldStr)) {
|
package/src/tools/file/read.ts
CHANGED
|
@@ -25,6 +25,15 @@ export const readTool: ToolDefinition = {
|
|
|
25
25
|
if (stat.isDirectory()) {
|
|
26
26
|
return { success: false, content: '', error: `Path is a directory: ${filePath}` }
|
|
27
27
|
}
|
|
28
|
+
// Prevent OOM on single-line files (e.g. 500MB JSON blob)
|
|
29
|
+
const MAX_FILE_SIZE = 50_000_000 // 50 MB
|
|
30
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
31
|
+
return {
|
|
32
|
+
success: false,
|
|
33
|
+
content: '',
|
|
34
|
+
error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
28
37
|
const offset = (params.offset as number) || 0
|
|
29
38
|
const limit = (params.limit as number) || 2000
|
|
30
39
|
const content = readFileSync(filePath, 'utf-8')
|
package/src/tools/file/write.ts
CHANGED
|
@@ -19,6 +19,16 @@ export const writeTool: ToolDefinition = {
|
|
|
19
19
|
async execute(params, ctx) {
|
|
20
20
|
const filePath = resolveSafe(ctx.cwd, params.file_path as string)
|
|
21
21
|
const content = params.content as string
|
|
22
|
+
|
|
23
|
+
const MAX_CONTENT_SIZE = 10_000_000 // 10 MB
|
|
24
|
+
if (content.length > MAX_CONTENT_SIZE) {
|
|
25
|
+
return {
|
|
26
|
+
success: false,
|
|
27
|
+
content: '',
|
|
28
|
+
error: `Content too large (${(content.length / 1e6).toFixed(1)} MB). Max: 10 MB.`,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
23
33
|
writeFileSync(filePath, content, 'utf-8')
|
|
24
34
|
const lines = content.split('\n').length
|