@geminilight/mindos 0.5.19 → 0.5.21
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/app/app/api/ask/route.ts +308 -172
- package/app/app/api/file/route.ts +35 -11
- package/app/app/api/skills/route.ts +22 -3
- package/app/components/SettingsModal.tsx +52 -58
- package/app/components/Sidebar.tsx +21 -1
- package/app/components/settings/AiTab.tsx +4 -25
- package/app/components/settings/AppearanceTab.tsx +31 -13
- package/app/components/settings/KnowledgeTab.tsx +13 -28
- package/app/components/settings/McpAgentInstall.tsx +227 -0
- package/app/components/settings/McpServerStatus.tsx +172 -0
- package/app/components/settings/McpSkillsSection.tsx +583 -0
- package/app/components/settings/McpTab.tsx +16 -728
- package/app/components/settings/PluginsTab.tsx +4 -27
- package/app/components/settings/Primitives.tsx +69 -0
- package/app/components/settings/ShortcutsTab.tsx +2 -4
- package/app/components/settings/SyncTab.tsx +8 -24
- package/app/components/settings/types.ts +116 -2
- package/app/lib/agent/context.ts +151 -87
- package/app/lib/agent/index.ts +4 -3
- package/app/lib/agent/model.ts +76 -10
- package/app/lib/agent/stream-consumer.ts +73 -77
- package/app/lib/agent/to-agent-messages.ts +106 -0
- package/app/lib/agent/tools.ts +260 -266
- package/app/lib/i18n-en.ts +480 -0
- package/app/lib/i18n-zh.ts +505 -0
- package/app/lib/i18n.ts +4 -947
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +7 -0
- package/app/package-lock.json +3258 -3093
- package/app/package.json +6 -3
- package/bin/cli.js +140 -5
- package/package.json +4 -1
- package/scripts/setup.js +13 -0
- package/skills/mindos/SKILL.md +10 -168
- package/skills/mindos-zh/SKILL.md +14 -172
- package/templates/skill-rules/en/skill-rules.md +222 -0
- package/templates/skill-rules/en/user-rules.md +20 -0
- package/templates/skill-rules/zh/skill-rules.md +222 -0
- package/templates/skill-rules/zh/user-rules.md +20 -0
package/app/app/api/ask/route.ts
CHANGED
|
@@ -1,107 +1,139 @@
|
|
|
1
1
|
export const dynamic = 'force-dynamic';
|
|
2
|
-
import {
|
|
2
|
+
import { Agent, type AgentEvent, type BeforeToolCallContext, type BeforeToolCallResult, type AfterToolCallContext, type AfterToolCallResult } from '@mariozechner/pi-agent-core';
|
|
3
3
|
import { NextRequest, NextResponse } from 'next/server';
|
|
4
4
|
import fs from 'fs';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { getFileContent, getMindRoot } from '@/lib/fs';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
7
|
+
import { getModelConfig } from '@/lib/agent/model';
|
|
8
|
+
import { knowledgeBaseTools, WRITE_TOOLS, truncate } from '@/lib/agent/tools';
|
|
9
|
+
import { AGENT_SYSTEM_PROMPT } from '@/lib/agent/prompt';
|
|
10
|
+
import { toAgentMessages } from '@/lib/agent/to-agent-messages';
|
|
11
|
+
import {
|
|
12
|
+
estimateTokens, estimateStringTokens, getContextLimit,
|
|
13
|
+
createTransformContext,
|
|
14
|
+
} from '@/lib/agent/context';
|
|
15
|
+
import { logAgentOp } from '@/lib/agent/log';
|
|
16
|
+
import { readSettings } from '@/lib/settings';
|
|
17
|
+
import { assertNotProtected } from '@/lib/core';
|
|
18
|
+
import type { Message as FrontendMessage } from '@/lib/types';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// MindOS SSE format — 6 event types (front-back contract)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
type MindOSSSEvent =
|
|
25
|
+
| { type: 'text_delta'; delta: string }
|
|
26
|
+
| { type: 'thinking_delta'; delta: string }
|
|
27
|
+
| { type: 'tool_start'; toolCallId: string; toolName: string; args: unknown }
|
|
28
|
+
| { type: 'tool_end'; toolCallId: string; output: string; isError: boolean }
|
|
29
|
+
| { type: 'done'; usage?: { input: number; output: number } }
|
|
30
|
+
| { type: 'error'; message: string };
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Type Guards for AgentEvent variants (safe event handling)
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function isTextDeltaEvent(e: AgentEvent): boolean {
|
|
37
|
+
return e.type === 'message_update' && (e as any).assistantMessageEvent?.type === 'text_delta';
|
|
38
|
+
}
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
function getTextDelta(e: AgentEvent): string {
|
|
41
|
+
return (e as any).assistantMessageEvent?.delta ?? '';
|
|
42
|
+
}
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (msg.content) {
|
|
38
|
-
result.push({ role: 'assistant', content: msg.content });
|
|
39
|
-
}
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
44
|
+
function isThinkingDeltaEvent(e: AgentEvent): boolean {
|
|
45
|
+
return e.type === 'message_update' && (e as any).assistantMessageEvent?.type === 'thinking_delta';
|
|
46
|
+
}
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
|
|
47
|
-
> = [];
|
|
48
|
-
const completedToolCalls: FrontendToolCallPart[] = [];
|
|
49
|
-
|
|
50
|
-
for (const part of msg.parts) {
|
|
51
|
-
if (part.type === 'text') {
|
|
52
|
-
if (part.text) {
|
|
53
|
-
assistantContent.push({ type: 'text', text: part.text });
|
|
54
|
-
}
|
|
55
|
-
} else if (part.type === 'tool-call') {
|
|
56
|
-
assistantContent.push({
|
|
57
|
-
type: 'tool-call',
|
|
58
|
-
toolCallId: part.toolCallId,
|
|
59
|
-
toolName: part.toolName,
|
|
60
|
-
input: part.input ?? {},
|
|
61
|
-
});
|
|
62
|
-
// Always emit a tool result for every tool call. Orphaned tool calls
|
|
63
|
-
// (running/pending from interrupted streams) get an empty result;
|
|
64
|
-
// without one the API rejects the request.
|
|
65
|
-
completedToolCalls.push(part);
|
|
66
|
-
}
|
|
67
|
-
// 'reasoning' parts are display-only; not sent back to model
|
|
68
|
-
}
|
|
48
|
+
function getThinkingDelta(e: AgentEvent): string {
|
|
49
|
+
return (e as any).assistantMessageEvent?.delta ?? '';
|
|
50
|
+
}
|
|
69
51
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
52
|
+
function isToolExecutionStartEvent(e: AgentEvent): boolean {
|
|
53
|
+
return e.type === 'tool_execution_start';
|
|
54
|
+
}
|
|
73
55
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
56
|
+
function getToolExecutionStart(e: AgentEvent): { toolCallId: string; toolName: string; args: unknown } {
|
|
57
|
+
const evt = e as any;
|
|
58
|
+
return {
|
|
59
|
+
toolCallId: evt.toolCallId ?? '',
|
|
60
|
+
toolName: evt.toolName ?? 'unknown',
|
|
61
|
+
args: evt.args ?? {},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isToolExecutionEndEvent(e: AgentEvent): boolean {
|
|
66
|
+
return e.type === 'tool_execution_end';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getToolExecutionEnd(e: AgentEvent): { toolCallId: string; output: string; isError: boolean } {
|
|
70
|
+
const evt = e as any;
|
|
71
|
+
const outputText = evt.result?.content
|
|
72
|
+
?.filter((p: any) => p.type === 'text')
|
|
73
|
+
.map((p: any) => p.text)
|
|
74
|
+
.join('') ?? '';
|
|
75
|
+
return {
|
|
76
|
+
toolCallId: evt.toolCallId ?? '',
|
|
77
|
+
output: outputText,
|
|
78
|
+
isError: !!evt.isError,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isTurnEndEvent(e: AgentEvent): boolean {
|
|
83
|
+
return e.type === 'turn_end';
|
|
84
|
+
}
|
|
87
85
|
|
|
88
|
-
|
|
86
|
+
function getTurnEndData(e: AgentEvent): { toolResults: Array<{ toolName: string; content: unknown }> } {
|
|
87
|
+
return {
|
|
88
|
+
toolResults: ((e as any).toolResults as any[]) ?? [],
|
|
89
|
+
};
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Helpers
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function readKnowledgeFile(filePath: string): { ok: boolean; content: string; truncated: boolean; error?: string } {
|
|
92
97
|
try {
|
|
93
|
-
|
|
98
|
+
const raw = getFileContent(filePath);
|
|
99
|
+
if (raw.length > 20_000) {
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
content: truncate(raw),
|
|
103
|
+
truncated: true,
|
|
104
|
+
error: undefined,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, content: raw, truncated: false };
|
|
94
108
|
} catch (err) {
|
|
95
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
content: '',
|
|
112
|
+
truncated: false,
|
|
113
|
+
error: err instanceof Error ? err.message : String(err),
|
|
114
|
+
};
|
|
96
115
|
}
|
|
97
116
|
}
|
|
98
117
|
|
|
99
|
-
function readAbsoluteFile(absPath: string): { ok: boolean; content: string; error?: string } {
|
|
118
|
+
function readAbsoluteFile(absPath: string): { ok: boolean; content: string; truncated: boolean; error?: string } {
|
|
100
119
|
try {
|
|
101
120
|
const raw = fs.readFileSync(absPath, 'utf-8');
|
|
102
|
-
|
|
121
|
+
if (raw.length > 20_000) {
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
content: truncate(raw),
|
|
125
|
+
truncated: true,
|
|
126
|
+
error: undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, content: raw, truncated: false };
|
|
103
130
|
} catch (err) {
|
|
104
|
-
return {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
content: '',
|
|
134
|
+
truncated: false,
|
|
135
|
+
error: err instanceof Error ? err.message : String(err),
|
|
136
|
+
};
|
|
105
137
|
}
|
|
106
138
|
}
|
|
107
139
|
|
|
@@ -113,6 +145,10 @@ function dirnameOf(filePath?: string): string | null {
|
|
|
113
145
|
return normalized.slice(0, idx);
|
|
114
146
|
}
|
|
115
147
|
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// POST /api/ask
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
116
152
|
export async function POST(req: NextRequest) {
|
|
117
153
|
let body: {
|
|
118
154
|
messages: FrontendMessage[];
|
|
@@ -130,8 +166,6 @@ export async function POST(req: NextRequest) {
|
|
|
130
166
|
const { messages, currentFile, attachedFiles, uploadedFiles } = body;
|
|
131
167
|
|
|
132
168
|
// Read agent config from settings
|
|
133
|
-
// NOTE: readSettings() is also called inside getModel() → effectiveAiConfig().
|
|
134
|
-
// Acceptable duplication — both are sync fs reads with identical results.
|
|
135
169
|
const serverSettings = readSettings();
|
|
136
170
|
const agentConfig = serverSettings.agent ?? {};
|
|
137
171
|
const stepLimit = Number.isFinite(body.maxSteps)
|
|
@@ -142,6 +176,9 @@ export async function POST(req: NextRequest) {
|
|
|
142
176
|
const contextStrategy = agentConfig.contextStrategy ?? 'auto';
|
|
143
177
|
|
|
144
178
|
// Auto-load skill + bootstrap context for each request.
|
|
179
|
+
// TODO (optimization): Consider caching bootstrap files with TTL to reduce per-request IO overhead.
|
|
180
|
+
// Current behavior: 8 synchronous file reads per request. For users with large knowledge bases,
|
|
181
|
+
// this adds ~10-50ms latency. Mitigation: Cache with 5min TTL or lazy-load on-demand.
|
|
145
182
|
const skillPath = path.resolve(process.cwd(), 'data/skills/mindos/SKILL.md');
|
|
146
183
|
const skill = readAbsoluteFile(skillPath);
|
|
147
184
|
|
|
@@ -157,21 +194,31 @@ export async function POST(req: NextRequest) {
|
|
|
157
194
|
target_config_md: targetDir ? readKnowledgeFile(`${targetDir}/CONFIG.md`) : null,
|
|
158
195
|
};
|
|
159
196
|
|
|
160
|
-
// Only report failures
|
|
197
|
+
// Only report failures + truncation warnings
|
|
161
198
|
const initFailures: string[] = [];
|
|
199
|
+
const truncationWarnings: string[] = [];
|
|
162
200
|
if (!skill.ok) initFailures.push(`skill.mindos: failed (${skill.error})`);
|
|
201
|
+
if (skill.ok && skill.truncated) truncationWarnings.push('skill.mindos was truncated');
|
|
163
202
|
if (!bootstrap.instruction.ok) initFailures.push(`bootstrap.instruction: failed (${bootstrap.instruction.error})`);
|
|
203
|
+
if (bootstrap.instruction.ok && bootstrap.instruction.truncated) truncationWarnings.push('bootstrap.instruction was truncated');
|
|
164
204
|
if (!bootstrap.index.ok) initFailures.push(`bootstrap.index: failed (${bootstrap.index.error})`);
|
|
205
|
+
if (bootstrap.index.ok && bootstrap.index.truncated) truncationWarnings.push('bootstrap.index was truncated');
|
|
165
206
|
if (!bootstrap.config_json.ok) initFailures.push(`bootstrap.config_json: failed (${bootstrap.config_json.error})`);
|
|
207
|
+
if (bootstrap.config_json.ok && bootstrap.config_json.truncated) truncationWarnings.push('bootstrap.config_json was truncated');
|
|
166
208
|
if (!bootstrap.config_md.ok) initFailures.push(`bootstrap.config_md: failed (${bootstrap.config_md.error})`);
|
|
209
|
+
if (bootstrap.config_md.ok && bootstrap.config_md.truncated) truncationWarnings.push('bootstrap.config_md was truncated');
|
|
167
210
|
if (bootstrap.target_readme && !bootstrap.target_readme.ok) initFailures.push(`bootstrap.target_readme: failed (${bootstrap.target_readme.error})`);
|
|
211
|
+
if (bootstrap.target_readme?.ok && bootstrap.target_readme.truncated) truncationWarnings.push('bootstrap.target_readme was truncated');
|
|
168
212
|
if (bootstrap.target_instruction && !bootstrap.target_instruction.ok) initFailures.push(`bootstrap.target_instruction: failed (${bootstrap.target_instruction.error})`);
|
|
213
|
+
if (bootstrap.target_instruction?.ok && bootstrap.target_instruction.truncated) truncationWarnings.push('bootstrap.target_instruction was truncated');
|
|
169
214
|
if (bootstrap.target_config_json && !bootstrap.target_config_json.ok) initFailures.push(`bootstrap.target_config_json: failed (${bootstrap.target_config_json.error})`);
|
|
215
|
+
if (bootstrap.target_config_json?.ok && bootstrap.target_config_json.truncated) truncationWarnings.push('bootstrap.target_config_json was truncated');
|
|
170
216
|
if (bootstrap.target_config_md && !bootstrap.target_config_md.ok) initFailures.push(`bootstrap.target_config_md: failed (${bootstrap.target_config_md.error})`);
|
|
217
|
+
if (bootstrap.target_config_md?.ok && bootstrap.target_config_md.truncated) truncationWarnings.push('bootstrap.target_config_md was truncated');
|
|
171
218
|
|
|
172
219
|
const initStatus = initFailures.length === 0
|
|
173
|
-
? `All initialization contexts loaded successfully. mind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}`
|
|
174
|
-
: `Initialization issues:\n${initFailures.join('\n')}\nmind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}`;
|
|
220
|
+
? `All initialization contexts loaded successfully. mind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}${truncationWarnings.length > 0 ? ` ⚠️ ${truncationWarnings.length} files truncated` : ''}`
|
|
221
|
+
: `Initialization issues:\n${initFailures.join('\n')}\nmind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}${truncationWarnings.length > 0 ? `\n⚠️ Warnings:\n${truncationWarnings.join('\n')}` : ''}`;
|
|
175
222
|
|
|
176
223
|
const initContextBlocks: string[] = [];
|
|
177
224
|
if (skill.ok) initContextBlocks.push(`## mindos_skill_md\n\n${skill.content}`);
|
|
@@ -190,13 +237,13 @@ export async function POST(req: NextRequest) {
|
|
|
190
237
|
const hasAttached = Array.isArray(attachedFiles) && attachedFiles.length > 0;
|
|
191
238
|
|
|
192
239
|
if (hasAttached) {
|
|
193
|
-
for (const filePath of attachedFiles) {
|
|
240
|
+
for (const filePath of attachedFiles!) {
|
|
194
241
|
if (seen.has(filePath)) continue;
|
|
195
242
|
seen.add(filePath);
|
|
196
243
|
try {
|
|
197
244
|
const content = truncate(getFileContent(filePath));
|
|
198
245
|
contextParts.push(`## Attached: ${filePath}\n\n${content}`);
|
|
199
|
-
} catch {}
|
|
246
|
+
} catch { /* ignore missing files */ }
|
|
200
247
|
}
|
|
201
248
|
}
|
|
202
249
|
|
|
@@ -205,11 +252,10 @@ export async function POST(req: NextRequest) {
|
|
|
205
252
|
try {
|
|
206
253
|
const content = truncate(getFileContent(currentFile));
|
|
207
254
|
contextParts.push(`## Current file: ${currentFile}\n\n${content}`);
|
|
208
|
-
} catch {}
|
|
255
|
+
} catch { /* ignore */ }
|
|
209
256
|
}
|
|
210
257
|
|
|
211
|
-
// Uploaded files
|
|
212
|
-
// treats them with high priority and never tries to look them up via tools.
|
|
258
|
+
// Uploaded files
|
|
213
259
|
const uploadedParts: string[] = [];
|
|
214
260
|
if (Array.isArray(uploadedFiles) && uploadedFiles.length > 0) {
|
|
215
261
|
for (const f of uploadedFiles.slice(0, 8)) {
|
|
@@ -242,97 +288,187 @@ export async function POST(req: NextRequest) {
|
|
|
242
288
|
const systemPrompt = promptParts.join('\n\n');
|
|
243
289
|
|
|
244
290
|
try {
|
|
245
|
-
const model =
|
|
246
|
-
const cfg = effectiveAiConfig();
|
|
247
|
-
const modelName = cfg.provider === 'openai' ? cfg.openaiModel : cfg.anthropicModel;
|
|
248
|
-
let modelMessages = convertToModelMessages(messages);
|
|
249
|
-
|
|
250
|
-
// Phase 3: Context management pipeline
|
|
251
|
-
// 1. Truncate tool outputs in historical messages
|
|
252
|
-
modelMessages = truncateToolOutputs(modelMessages);
|
|
253
|
-
|
|
254
|
-
const preTokens = estimateTokens(modelMessages);
|
|
255
|
-
const sysTokens = estimateStringTokens(systemPrompt);
|
|
256
|
-
const ctxLimit = getContextLimit(modelName);
|
|
257
|
-
console.log(`[ask] Context: ~${preTokens + sysTokens} tokens (messages=${preTokens}, system=${sysTokens}), limit=${ctxLimit}`);
|
|
258
|
-
|
|
259
|
-
// 2. Compact if >70% context limit (skip if user disabled)
|
|
260
|
-
if (contextStrategy === 'auto' && needsCompact(modelMessages, systemPrompt, modelName)) {
|
|
261
|
-
console.log('[ask] Context >70% limit, compacting...');
|
|
262
|
-
const result = await compactMessages(modelMessages, model);
|
|
263
|
-
modelMessages = result.messages;
|
|
264
|
-
if (result.compacted) {
|
|
265
|
-
const postTokens = estimateTokens(modelMessages);
|
|
266
|
-
console.log(`[ask] After compact: ~${postTokens + sysTokens} tokens`);
|
|
267
|
-
} else {
|
|
268
|
-
console.log('[ask] Compact skipped (too few messages), hard prune will handle overflow if needed');
|
|
269
|
-
}
|
|
270
|
-
}
|
|
291
|
+
const { model, modelName, apiKey, provider } = getModelConfig();
|
|
271
292
|
|
|
272
|
-
//
|
|
273
|
-
|
|
293
|
+
// Convert frontend messages to AgentMessage[]
|
|
294
|
+
const agentMessages = toAgentMessages(messages);
|
|
274
295
|
|
|
275
|
-
//
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const result = streamText({
|
|
281
|
-
model,
|
|
282
|
-
system: systemPrompt,
|
|
283
|
-
messages: modelMessages,
|
|
284
|
-
tools: knowledgeBaseTools,
|
|
285
|
-
stopWhen: stepCountIs(stepLimit),
|
|
286
|
-
...(enableThinking && cfg.provider === 'anthropic' ? {
|
|
287
|
-
providerOptions: {
|
|
288
|
-
anthropic: {
|
|
289
|
-
thinking: { type: 'enabled', budgetTokens: thinkingBudget },
|
|
290
|
-
},
|
|
291
|
-
},
|
|
292
|
-
} : {}),
|
|
296
|
+
// Extract the last user message for agent.prompt()
|
|
297
|
+
const lastUserContent = messages.length > 0 && messages[messages.length - 1].role === 'user'
|
|
298
|
+
? messages[messages.length - 1].content
|
|
299
|
+
: '';
|
|
293
300
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
301
|
+
// History = all messages except the last user message (agent.prompt adds it)
|
|
302
|
+
const historyMessages = agentMessages.slice(0, -1);
|
|
303
|
+
|
|
304
|
+
// Capture API key for this request — safe since each POST creates a new Agent instance.
|
|
305
|
+
// Even though JS closures are lexically scoped, being explicit guards against future refactors.
|
|
306
|
+
const requestApiKey = apiKey;
|
|
307
|
+
|
|
308
|
+
// ── Loop detection state ──
|
|
309
|
+
const stepHistory: Array<{ tool: string; input: string }> = [];
|
|
310
|
+
let stepCount = 0;
|
|
311
|
+
let loopCooldown = 0;
|
|
312
|
+
|
|
313
|
+
// ── Create Agent (per-request lifecycle) ──
|
|
314
|
+
const agent = new Agent({
|
|
315
|
+
initialState: {
|
|
316
|
+
systemPrompt,
|
|
317
|
+
model,
|
|
318
|
+
thinkingLevel: (enableThinking && provider === 'anthropic') ? 'medium' : 'off',
|
|
319
|
+
tools: knowledgeBaseTools,
|
|
320
|
+
messages: historyMessages,
|
|
321
|
+
},
|
|
322
|
+
getApiKey: async () => requestApiKey,
|
|
323
|
+
toolExecution: 'parallel',
|
|
324
|
+
|
|
325
|
+
// Context management: truncate → compact → prune
|
|
326
|
+
transformContext: createTransformContext(
|
|
327
|
+
systemPrompt,
|
|
328
|
+
modelName,
|
|
329
|
+
() => model,
|
|
330
|
+
apiKey,
|
|
331
|
+
contextStrategy,
|
|
332
|
+
),
|
|
333
|
+
|
|
334
|
+
// Write-protection: block writes to protected files
|
|
335
|
+
beforeToolCall: async (context: BeforeToolCallContext): Promise<BeforeToolCallResult | undefined> => {
|
|
336
|
+
const { toolCall, args } = context;
|
|
337
|
+
// toolCall is an object with type "toolCall" and contains the tool name and ID
|
|
338
|
+
const toolName = (toolCall as any).toolName ?? (toolCall as any).name;
|
|
339
|
+
if (toolName && WRITE_TOOLS.has(toolName)) {
|
|
340
|
+
const filePath = (args as any).path ?? (args as any).from_path;
|
|
341
|
+
if (filePath) {
|
|
342
|
+
try {
|
|
343
|
+
assertNotProtected(filePath, 'modified by AI agent');
|
|
344
|
+
} catch (e) {
|
|
345
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
346
|
+
return {
|
|
347
|
+
block: true,
|
|
348
|
+
reason: `Write-protection error: ${errorMsg}`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
308
351
|
}
|
|
309
352
|
}
|
|
310
|
-
|
|
353
|
+
return undefined;
|
|
311
354
|
},
|
|
312
355
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
356
|
+
// Logging: record all tool executions
|
|
357
|
+
afterToolCall: async (context: AfterToolCallContext): Promise<AfterToolCallResult | undefined> => {
|
|
358
|
+
const ts = new Date().toISOString();
|
|
359
|
+
const { toolCall, args, result, isError } = context;
|
|
360
|
+
const toolName = (toolCall as any).toolName ?? (toolCall as any).name;
|
|
361
|
+
const outputText = result?.content
|
|
362
|
+
?.filter((p: any) => p.type === 'text')
|
|
363
|
+
.map((p: any) => p.text)
|
|
364
|
+
.join('') ?? '';
|
|
365
|
+
try {
|
|
366
|
+
logAgentOp({
|
|
367
|
+
ts,
|
|
368
|
+
tool: toolName ?? 'unknown',
|
|
369
|
+
params: args as Record<string, unknown>,
|
|
370
|
+
result: isError ? 'error' : 'ok',
|
|
371
|
+
message: outputText.slice(0, 200),
|
|
372
|
+
});
|
|
373
|
+
} catch { /* logging must never kill the stream */ }
|
|
374
|
+
return undefined;
|
|
328
375
|
},
|
|
329
376
|
|
|
330
|
-
|
|
331
|
-
|
|
377
|
+
...(enableThinking && provider === 'anthropic' ? {
|
|
378
|
+
thinkingBudgets: { medium: thinkingBudget },
|
|
379
|
+
} : {}),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// ── SSE Stream ──
|
|
383
|
+
const encoder = new TextEncoder();
|
|
384
|
+
const stream = new ReadableStream({
|
|
385
|
+
start(controller) {
|
|
386
|
+
function send(event: MindOSSSEvent) {
|
|
387
|
+
try {
|
|
388
|
+
controller.enqueue(encoder.encode(`data:${JSON.stringify(event)}\n\n`));
|
|
389
|
+
} catch { /* controller may be closed */ }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
agent.subscribe((event: AgentEvent) => {
|
|
393
|
+
if (isTextDeltaEvent(event)) {
|
|
394
|
+
send({ type: 'text_delta', delta: getTextDelta(event) });
|
|
395
|
+
} else if (isThinkingDeltaEvent(event)) {
|
|
396
|
+
send({ type: 'thinking_delta', delta: getThinkingDelta(event) });
|
|
397
|
+
} else if (isToolExecutionStartEvent(event)) {
|
|
398
|
+
const { toolCallId, toolName, args } = getToolExecutionStart(event);
|
|
399
|
+
send({
|
|
400
|
+
type: 'tool_start',
|
|
401
|
+
toolCallId,
|
|
402
|
+
toolName,
|
|
403
|
+
args,
|
|
404
|
+
});
|
|
405
|
+
} else if (isToolExecutionEndEvent(event)) {
|
|
406
|
+
const { toolCallId, output, isError } = getToolExecutionEnd(event);
|
|
407
|
+
send({
|
|
408
|
+
type: 'tool_end',
|
|
409
|
+
toolCallId,
|
|
410
|
+
output,
|
|
411
|
+
isError,
|
|
412
|
+
});
|
|
413
|
+
} else if (isTurnEndEvent(event)) {
|
|
414
|
+
stepCount++;
|
|
415
|
+
|
|
416
|
+
// Track tool calls for loop detection (lock-free batch update).
|
|
417
|
+
// Deterministic JSON.stringify ensures consistent input comparison.
|
|
418
|
+
const { toolResults } = getTurnEndData(event);
|
|
419
|
+
if (Array.isArray(toolResults) && toolResults.length > 0) {
|
|
420
|
+
const newEntries = toolResults.map(tr => ({
|
|
421
|
+
tool: tr.toolName ?? 'unknown',
|
|
422
|
+
input: JSON.stringify(tr.content, null, 0), // Deterministic (no whitespace)
|
|
423
|
+
}));
|
|
424
|
+
stepHistory.push(...newEntries);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Loop detection: same tool + same args 3 times in a row.
|
|
428
|
+
// Only trigger if we have 3+ history entries (prevent false positives on first turn).
|
|
429
|
+
const LOOP_DETECTION_THRESHOLD = 3;
|
|
430
|
+
if (loopCooldown > 0) {
|
|
431
|
+
loopCooldown--;
|
|
432
|
+
} else if (stepHistory.length >= LOOP_DETECTION_THRESHOLD) {
|
|
433
|
+
const lastN = stepHistory.slice(-LOOP_DETECTION_THRESHOLD);
|
|
434
|
+
if (lastN.every(s => s.tool === lastN[0].tool && s.input === lastN[0].input)) {
|
|
435
|
+
loopCooldown = 3;
|
|
436
|
+
// TODO (metrics): Track loop detection rate — metrics.increment('agent.loop_detected', { model: modelName })
|
|
437
|
+
agent.steer({
|
|
438
|
+
role: 'user',
|
|
439
|
+
content: '[SYSTEM WARNING] You have called the same tool with identical arguments 3 times in a row. This appears to be a loop. Try a completely different approach or ask the user for clarification.',
|
|
440
|
+
timestamp: Date.now(),
|
|
441
|
+
} as any);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Step limit enforcement
|
|
446
|
+
if (stepCount >= stepLimit) {
|
|
447
|
+
agent.abort();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
console.log(`[ask] Step ${stepCount}/${stepLimit}`);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
agent.prompt(lastUserContent).then(() => {
|
|
455
|
+
send({ type: 'done' });
|
|
456
|
+
controller.close();
|
|
457
|
+
}).catch((err) => {
|
|
458
|
+
send({ type: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
459
|
+
controller.close();
|
|
460
|
+
});
|
|
332
461
|
},
|
|
333
462
|
});
|
|
334
463
|
|
|
335
|
-
return
|
|
464
|
+
return new Response(stream, {
|
|
465
|
+
headers: {
|
|
466
|
+
'Content-Type': 'text/event-stream',
|
|
467
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
468
|
+
'Connection': 'keep-alive',
|
|
469
|
+
'X-Accel-Buffering': 'no',
|
|
470
|
+
},
|
|
471
|
+
});
|
|
336
472
|
} catch (err) {
|
|
337
473
|
console.error('[ask] Failed to initialize model:', err);
|
|
338
474
|
return NextResponse.json(
|