@geminilight/mindos 0.5.20 → 0.5.22
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 +343 -178
- package/app/app/api/monitoring/route.ts +95 -0
- package/app/components/SettingsModal.tsx +58 -58
- package/app/components/settings/AgentsTab.tsx +240 -0
- 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 +17 -959
- package/app/components/settings/MonitoringTab.tsx +202 -0
- 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/instrumentation.ts +7 -2
- package/app/lib/agent/context.ts +151 -87
- package/app/lib/agent/index.ts +5 -3
- package/app/lib/agent/log.ts +1 -0
- package/app/lib/agent/model.ts +76 -10
- package/app/lib/agent/skill-rules.ts +70 -0
- 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/api.ts +12 -3
- package/app/lib/core/csv.ts +2 -1
- package/app/lib/core/fs-ops.ts +7 -6
- package/app/lib/core/index.ts +1 -1
- package/app/lib/core/lines.ts +7 -6
- package/app/lib/core/search-index.ts +174 -0
- package/app/lib/core/search.ts +30 -1
- package/app/lib/core/security.ts +6 -3
- package/app/lib/errors.ts +108 -0
- package/app/lib/fs.ts +6 -3
- package/app/lib/i18n-en.ts +523 -0
- package/app/lib/i18n-zh.ts +548 -0
- package/app/lib/i18n.ts +4 -963
- package/app/lib/metrics.ts +81 -0
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -1
- package/app/package-lock.json +3258 -3093
- package/app/package.json +6 -3
- package/bin/cli.js +7 -4
- package/package.json +4 -1
package/app/app/api/ask/route.ts
CHANGED
|
@@ -1,107 +1,142 @@
|
|
|
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 { loadSkillRules } from '@/lib/agent/skill-rules';
|
|
17
|
+
import { readSettings } from '@/lib/settings';
|
|
18
|
+
import { MindOSError, apiError, ErrorCodes } from '@/lib/errors';
|
|
19
|
+
import { metrics } from '@/lib/metrics';
|
|
20
|
+
import { assertNotProtected } from '@/lib/core';
|
|
21
|
+
import type { Message as FrontendMessage } from '@/lib/types';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// MindOS SSE format — 6 event types (front-back contract)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
type MindOSSSEvent =
|
|
28
|
+
| { type: 'text_delta'; delta: string }
|
|
29
|
+
| { type: 'thinking_delta'; delta: string }
|
|
30
|
+
| { type: 'tool_start'; toolCallId: string; toolName: string; args: unknown }
|
|
31
|
+
| { type: 'tool_end'; toolCallId: string; output: string; isError: boolean }
|
|
32
|
+
| { type: 'done'; usage?: { input: number; output: number } }
|
|
33
|
+
| { type: 'error'; message: string };
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Type Guards for AgentEvent variants (safe event handling)
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function isTextDeltaEvent(e: AgentEvent): boolean {
|
|
40
|
+
return e.type === 'message_update' && (e as any).assistantMessageEvent?.type === 'text_delta';
|
|
41
|
+
}
|
|
30
42
|
|
|
31
|
-
|
|
32
|
-
|
|
43
|
+
function getTextDelta(e: AgentEvent): string {
|
|
44
|
+
return (e as any).assistantMessageEvent?.delta ?? '';
|
|
45
|
+
}
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (msg.content) {
|
|
38
|
-
result.push({ role: 'assistant', content: msg.content });
|
|
39
|
-
}
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
47
|
+
function isThinkingDeltaEvent(e: AgentEvent): boolean {
|
|
48
|
+
return e.type === 'message_update' && (e as any).assistantMessageEvent?.type === 'thinking_delta';
|
|
49
|
+
}
|
|
42
50
|
|
|
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
|
-
}
|
|
51
|
+
function getThinkingDelta(e: AgentEvent): string {
|
|
52
|
+
return (e as any).assistantMessageEvent?.delta ?? '';
|
|
53
|
+
}
|
|
69
54
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
55
|
+
function isToolExecutionStartEvent(e: AgentEvent): boolean {
|
|
56
|
+
return e.type === 'tool_execution_start';
|
|
57
|
+
}
|
|
73
58
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
59
|
+
function getToolExecutionStart(e: AgentEvent): { toolCallId: string; toolName: string; args: unknown } {
|
|
60
|
+
const evt = e as any;
|
|
61
|
+
return {
|
|
62
|
+
toolCallId: evt.toolCallId ?? '',
|
|
63
|
+
toolName: evt.toolName ?? 'unknown',
|
|
64
|
+
args: evt.args ?? {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isToolExecutionEndEvent(e: AgentEvent): boolean {
|
|
69
|
+
return e.type === 'tool_execution_end';
|
|
70
|
+
}
|
|
87
71
|
|
|
88
|
-
|
|
72
|
+
function getToolExecutionEnd(e: AgentEvent): { toolCallId: string; output: string; isError: boolean } {
|
|
73
|
+
const evt = e as any;
|
|
74
|
+
const outputText = evt.result?.content
|
|
75
|
+
?.filter((p: any) => p.type === 'text')
|
|
76
|
+
.map((p: any) => p.text)
|
|
77
|
+
.join('') ?? '';
|
|
78
|
+
return {
|
|
79
|
+
toolCallId: evt.toolCallId ?? '',
|
|
80
|
+
output: outputText,
|
|
81
|
+
isError: !!evt.isError,
|
|
82
|
+
};
|
|
89
83
|
}
|
|
90
84
|
|
|
91
|
-
function
|
|
85
|
+
function isTurnEndEvent(e: AgentEvent): boolean {
|
|
86
|
+
return e.type === 'turn_end';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getTurnEndData(e: AgentEvent): { toolResults: Array<{ toolName: string; content: unknown }> } {
|
|
90
|
+
return {
|
|
91
|
+
toolResults: ((e as any).toolResults as any[]) ?? [],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Helpers
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function readKnowledgeFile(filePath: string): { ok: boolean; content: string; truncated: boolean; error?: string } {
|
|
92
100
|
try {
|
|
93
|
-
|
|
101
|
+
const raw = getFileContent(filePath);
|
|
102
|
+
if (raw.length > 20_000) {
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
content: truncate(raw),
|
|
106
|
+
truncated: true,
|
|
107
|
+
error: undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, content: raw, truncated: false };
|
|
94
111
|
} catch (err) {
|
|
95
|
-
return {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
content: '',
|
|
115
|
+
truncated: false,
|
|
116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
117
|
+
};
|
|
96
118
|
}
|
|
97
119
|
}
|
|
98
120
|
|
|
99
|
-
function readAbsoluteFile(absPath: string): { ok: boolean; content: string; error?: string } {
|
|
121
|
+
function readAbsoluteFile(absPath: string): { ok: boolean; content: string; truncated: boolean; error?: string } {
|
|
100
122
|
try {
|
|
101
123
|
const raw = fs.readFileSync(absPath, 'utf-8');
|
|
102
|
-
|
|
124
|
+
if (raw.length > 20_000) {
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
content: truncate(raw),
|
|
128
|
+
truncated: true,
|
|
129
|
+
error: undefined,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { ok: true, content: raw, truncated: false };
|
|
103
133
|
} catch (err) {
|
|
104
|
-
return {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
content: '',
|
|
137
|
+
truncated: false,
|
|
138
|
+
error: err instanceof Error ? err.message : String(err),
|
|
139
|
+
};
|
|
105
140
|
}
|
|
106
141
|
}
|
|
107
142
|
|
|
@@ -113,6 +148,10 @@ function dirnameOf(filePath?: string): string | null {
|
|
|
113
148
|
return normalized.slice(0, idx);
|
|
114
149
|
}
|
|
115
150
|
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// POST /api/ask
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
116
155
|
export async function POST(req: NextRequest) {
|
|
117
156
|
let body: {
|
|
118
157
|
messages: FrontendMessage[];
|
|
@@ -124,14 +163,12 @@ export async function POST(req: NextRequest) {
|
|
|
124
163
|
try {
|
|
125
164
|
body = await req.json();
|
|
126
165
|
} catch {
|
|
127
|
-
return
|
|
166
|
+
return apiError(ErrorCodes.INVALID_REQUEST, 'Invalid JSON body', 400);
|
|
128
167
|
}
|
|
129
168
|
|
|
130
169
|
const { messages, currentFile, attachedFiles, uploadedFiles } = body;
|
|
131
170
|
|
|
132
171
|
// 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
172
|
const serverSettings = readSettings();
|
|
136
173
|
const agentConfig = serverSettings.agent ?? {};
|
|
137
174
|
const stepLimit = Number.isFinite(body.maxSteps)
|
|
@@ -142,9 +179,18 @@ export async function POST(req: NextRequest) {
|
|
|
142
179
|
const contextStrategy = agentConfig.contextStrategy ?? 'auto';
|
|
143
180
|
|
|
144
181
|
// Auto-load skill + bootstrap context for each request.
|
|
145
|
-
|
|
182
|
+
// 1. SKILL.md — static trigger + protocol (always loaded)
|
|
183
|
+
// 2. skill-rules.md — user's knowledge base operating rules (if exists)
|
|
184
|
+
// 3. user-rules.md — user's personalized rules (if exists)
|
|
185
|
+
const isZh = serverSettings.disabledSkills?.includes('mindos') ?? false;
|
|
186
|
+
const skillDirName = isZh ? 'mindos-zh' : 'mindos';
|
|
187
|
+
const skillPath = path.resolve(process.cwd(), `data/skills/${skillDirName}/SKILL.md`);
|
|
146
188
|
const skill = readAbsoluteFile(skillPath);
|
|
147
189
|
|
|
190
|
+
// Progressive skill loading: read skill-rules + user-rules from knowledge base
|
|
191
|
+
const mindRoot = getMindRoot();
|
|
192
|
+
const { skillRules, userRules } = loadSkillRules(mindRoot, skillDirName);
|
|
193
|
+
|
|
148
194
|
const targetDir = dirnameOf(currentFile);
|
|
149
195
|
const bootstrap = {
|
|
150
196
|
instruction: readKnowledgeFile('INSTRUCTION.md'),
|
|
@@ -157,24 +203,43 @@ export async function POST(req: NextRequest) {
|
|
|
157
203
|
target_config_md: targetDir ? readKnowledgeFile(`${targetDir}/CONFIG.md`) : null,
|
|
158
204
|
};
|
|
159
205
|
|
|
160
|
-
// Only report failures
|
|
206
|
+
// Only report failures + truncation warnings
|
|
161
207
|
const initFailures: string[] = [];
|
|
208
|
+
const truncationWarnings: string[] = [];
|
|
162
209
|
if (!skill.ok) initFailures.push(`skill.mindos: failed (${skill.error})`);
|
|
210
|
+
if (skill.ok && skill.truncated) truncationWarnings.push('skill.mindos was truncated');
|
|
211
|
+
if (skillRules.ok && skillRules.truncated) truncationWarnings.push('skill-rules.md was truncated');
|
|
212
|
+
if (userRules.ok && userRules.truncated) truncationWarnings.push('user-rules.md was truncated');
|
|
163
213
|
if (!bootstrap.instruction.ok) initFailures.push(`bootstrap.instruction: failed (${bootstrap.instruction.error})`);
|
|
214
|
+
if (bootstrap.instruction.ok && bootstrap.instruction.truncated) truncationWarnings.push('bootstrap.instruction was truncated');
|
|
164
215
|
if (!bootstrap.index.ok) initFailures.push(`bootstrap.index: failed (${bootstrap.index.error})`);
|
|
216
|
+
if (bootstrap.index.ok && bootstrap.index.truncated) truncationWarnings.push('bootstrap.index was truncated');
|
|
165
217
|
if (!bootstrap.config_json.ok) initFailures.push(`bootstrap.config_json: failed (${bootstrap.config_json.error})`);
|
|
218
|
+
if (bootstrap.config_json.ok && bootstrap.config_json.truncated) truncationWarnings.push('bootstrap.config_json was truncated');
|
|
166
219
|
if (!bootstrap.config_md.ok) initFailures.push(`bootstrap.config_md: failed (${bootstrap.config_md.error})`);
|
|
220
|
+
if (bootstrap.config_md.ok && bootstrap.config_md.truncated) truncationWarnings.push('bootstrap.config_md was truncated');
|
|
167
221
|
if (bootstrap.target_readme && !bootstrap.target_readme.ok) initFailures.push(`bootstrap.target_readme: failed (${bootstrap.target_readme.error})`);
|
|
222
|
+
if (bootstrap.target_readme?.ok && bootstrap.target_readme.truncated) truncationWarnings.push('bootstrap.target_readme was truncated');
|
|
168
223
|
if (bootstrap.target_instruction && !bootstrap.target_instruction.ok) initFailures.push(`bootstrap.target_instruction: failed (${bootstrap.target_instruction.error})`);
|
|
224
|
+
if (bootstrap.target_instruction?.ok && bootstrap.target_instruction.truncated) truncationWarnings.push('bootstrap.target_instruction was truncated');
|
|
169
225
|
if (bootstrap.target_config_json && !bootstrap.target_config_json.ok) initFailures.push(`bootstrap.target_config_json: failed (${bootstrap.target_config_json.error})`);
|
|
226
|
+
if (bootstrap.target_config_json?.ok && bootstrap.target_config_json.truncated) truncationWarnings.push('bootstrap.target_config_json was truncated');
|
|
170
227
|
if (bootstrap.target_config_md && !bootstrap.target_config_md.ok) initFailures.push(`bootstrap.target_config_md: failed (${bootstrap.target_config_md.error})`);
|
|
228
|
+
if (bootstrap.target_config_md?.ok && bootstrap.target_config_md.truncated) truncationWarnings.push('bootstrap.target_config_md was truncated');
|
|
171
229
|
|
|
172
230
|
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}` : ''}`;
|
|
231
|
+
? `All initialization contexts loaded successfully. mind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}${truncationWarnings.length > 0 ? ` ⚠️ ${truncationWarnings.length} files truncated` : ''}`
|
|
232
|
+
: `Initialization issues:\n${initFailures.join('\n')}\nmind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}${truncationWarnings.length > 0 ? `\n⚠️ Warnings:\n${truncationWarnings.join('\n')}` : ''}`;
|
|
175
233
|
|
|
176
234
|
const initContextBlocks: string[] = [];
|
|
177
235
|
if (skill.ok) initContextBlocks.push(`## mindos_skill_md\n\n${skill.content}`);
|
|
236
|
+
// Progressive skill loading: inject skill-rules and user-rules after SKILL.md
|
|
237
|
+
if (skillRules.ok && !skillRules.empty) {
|
|
238
|
+
initContextBlocks.push(`## skill_rules\n\nOperating rules loaded from knowledge base (.agents/skills/${skillDirName}/skill-rules.md):\n\n${skillRules.content}`);
|
|
239
|
+
}
|
|
240
|
+
if (userRules.ok && !userRules.empty) {
|
|
241
|
+
initContextBlocks.push(`## user_rules\n\nUser personalization rules (.agents/skills/${skillDirName}/user-rules.md):\n\n${userRules.content}`);
|
|
242
|
+
}
|
|
178
243
|
if (bootstrap.instruction.ok) initContextBlocks.push(`## bootstrap_instruction\n\n${bootstrap.instruction.content}`);
|
|
179
244
|
if (bootstrap.index.ok) initContextBlocks.push(`## bootstrap_index\n\n${bootstrap.index.content}`);
|
|
180
245
|
if (bootstrap.config_json.ok) initContextBlocks.push(`## bootstrap_config_json\n\n${bootstrap.config_json.content}`);
|
|
@@ -190,13 +255,13 @@ export async function POST(req: NextRequest) {
|
|
|
190
255
|
const hasAttached = Array.isArray(attachedFiles) && attachedFiles.length > 0;
|
|
191
256
|
|
|
192
257
|
if (hasAttached) {
|
|
193
|
-
for (const filePath of attachedFiles) {
|
|
258
|
+
for (const filePath of attachedFiles!) {
|
|
194
259
|
if (seen.has(filePath)) continue;
|
|
195
260
|
seen.add(filePath);
|
|
196
261
|
try {
|
|
197
262
|
const content = truncate(getFileContent(filePath));
|
|
198
263
|
contextParts.push(`## Attached: ${filePath}\n\n${content}`);
|
|
199
|
-
} catch {}
|
|
264
|
+
} catch { /* ignore missing files */ }
|
|
200
265
|
}
|
|
201
266
|
}
|
|
202
267
|
|
|
@@ -205,11 +270,10 @@ export async function POST(req: NextRequest) {
|
|
|
205
270
|
try {
|
|
206
271
|
const content = truncate(getFileContent(currentFile));
|
|
207
272
|
contextParts.push(`## Current file: ${currentFile}\n\n${content}`);
|
|
208
|
-
} catch {}
|
|
273
|
+
} catch { /* ignore */ }
|
|
209
274
|
}
|
|
210
275
|
|
|
211
|
-
// Uploaded files
|
|
212
|
-
// treats them with high priority and never tries to look them up via tools.
|
|
276
|
+
// Uploaded files
|
|
213
277
|
const uploadedParts: string[] = [];
|
|
214
278
|
if (Array.isArray(uploadedFiles) && uploadedFiles.length > 0) {
|
|
215
279
|
for (const f of uploadedFiles.slice(0, 8)) {
|
|
@@ -242,102 +306,203 @@ export async function POST(req: NextRequest) {
|
|
|
242
306
|
const systemPrompt = promptParts.join('\n\n');
|
|
243
307
|
|
|
244
308
|
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
|
-
}
|
|
309
|
+
const { model, modelName, apiKey, provider } = getModelConfig();
|
|
271
310
|
|
|
272
|
-
//
|
|
273
|
-
|
|
311
|
+
// Convert frontend messages to AgentMessage[]
|
|
312
|
+
const agentMessages = toAgentMessages(messages);
|
|
274
313
|
|
|
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
|
-
} : {}),
|
|
314
|
+
// Extract the last user message for agent.prompt()
|
|
315
|
+
const lastUserContent = messages.length > 0 && messages[messages.length - 1].role === 'user'
|
|
316
|
+
? messages[messages.length - 1].content
|
|
317
|
+
: '';
|
|
293
318
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
319
|
+
// History = all messages except the last user message (agent.prompt adds it)
|
|
320
|
+
const historyMessages = agentMessages.slice(0, -1);
|
|
321
|
+
|
|
322
|
+
// Capture API key for this request — safe since each POST creates a new Agent instance.
|
|
323
|
+
// Even though JS closures are lexically scoped, being explicit guards against future refactors.
|
|
324
|
+
const requestApiKey = apiKey;
|
|
325
|
+
|
|
326
|
+
// ── Loop detection state ──
|
|
327
|
+
const stepHistory: Array<{ tool: string; input: string }> = [];
|
|
328
|
+
let stepCount = 0;
|
|
329
|
+
let loopCooldown = 0;
|
|
330
|
+
|
|
331
|
+
// ── Create Agent (per-request lifecycle) ──
|
|
332
|
+
const agent = new Agent({
|
|
333
|
+
initialState: {
|
|
334
|
+
systemPrompt,
|
|
335
|
+
model,
|
|
336
|
+
thinkingLevel: (enableThinking && provider === 'anthropic') ? 'medium' : 'off',
|
|
337
|
+
tools: knowledgeBaseTools,
|
|
338
|
+
messages: historyMessages,
|
|
339
|
+
},
|
|
340
|
+
getApiKey: async () => requestApiKey,
|
|
341
|
+
toolExecution: 'parallel',
|
|
342
|
+
|
|
343
|
+
// Context management: truncate → compact → prune
|
|
344
|
+
transformContext: createTransformContext(
|
|
345
|
+
systemPrompt,
|
|
346
|
+
modelName,
|
|
347
|
+
() => model,
|
|
348
|
+
apiKey,
|
|
349
|
+
contextStrategy,
|
|
350
|
+
),
|
|
351
|
+
|
|
352
|
+
// Write-protection: block writes to protected files
|
|
353
|
+
beforeToolCall: async (context: BeforeToolCallContext): Promise<BeforeToolCallResult | undefined> => {
|
|
354
|
+
const { toolCall, args } = context;
|
|
355
|
+
// toolCall is an object with type "toolCall" and contains the tool name and ID
|
|
356
|
+
const toolName = (toolCall as any).toolName ?? (toolCall as any).name;
|
|
357
|
+
if (toolName && WRITE_TOOLS.has(toolName)) {
|
|
358
|
+
const filePath = (args as any).path ?? (args as any).from_path;
|
|
359
|
+
if (filePath) {
|
|
360
|
+
try {
|
|
361
|
+
assertNotProtected(filePath, 'modified by AI agent');
|
|
362
|
+
} catch (e) {
|
|
363
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
364
|
+
return {
|
|
365
|
+
block: true,
|
|
366
|
+
reason: `Write-protection error: ${errorMsg}`,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
308
369
|
}
|
|
309
370
|
}
|
|
310
|
-
|
|
371
|
+
return undefined;
|
|
311
372
|
},
|
|
312
373
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
374
|
+
// Logging: record all tool executions
|
|
375
|
+
afterToolCall: async (context: AfterToolCallContext): Promise<AfterToolCallResult | undefined> => {
|
|
376
|
+
const ts = new Date().toISOString();
|
|
377
|
+
const { toolCall, args, result, isError } = context;
|
|
378
|
+
const toolName = (toolCall as any).toolName ?? (toolCall as any).name;
|
|
379
|
+
const outputText = result?.content
|
|
380
|
+
?.filter((p: any) => p.type === 'text')
|
|
381
|
+
.map((p: any) => p.text)
|
|
382
|
+
.join('') ?? '';
|
|
383
|
+
try {
|
|
384
|
+
logAgentOp({
|
|
385
|
+
ts,
|
|
386
|
+
tool: toolName ?? 'unknown',
|
|
387
|
+
params: args as Record<string, unknown>,
|
|
388
|
+
result: isError ? 'error' : 'ok',
|
|
389
|
+
message: outputText.slice(0, 200),
|
|
390
|
+
});
|
|
391
|
+
} catch { /* logging must never kill the stream */ }
|
|
392
|
+
return undefined;
|
|
328
393
|
},
|
|
329
394
|
|
|
330
|
-
|
|
331
|
-
|
|
395
|
+
...(enableThinking && provider === 'anthropic' ? {
|
|
396
|
+
thinkingBudgets: { medium: thinkingBudget },
|
|
397
|
+
} : {}),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ── SSE Stream ──
|
|
401
|
+
const encoder = new TextEncoder();
|
|
402
|
+
const requestStartTime = Date.now();
|
|
403
|
+
const stream = new ReadableStream({
|
|
404
|
+
start(controller) {
|
|
405
|
+
function send(event: MindOSSSEvent) {
|
|
406
|
+
try {
|
|
407
|
+
controller.enqueue(encoder.encode(`data:${JSON.stringify(event)}\n\n`));
|
|
408
|
+
} catch { /* controller may be closed */ }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
agent.subscribe((event: AgentEvent) => {
|
|
412
|
+
if (isTextDeltaEvent(event)) {
|
|
413
|
+
send({ type: 'text_delta', delta: getTextDelta(event) });
|
|
414
|
+
} else if (isThinkingDeltaEvent(event)) {
|
|
415
|
+
send({ type: 'thinking_delta', delta: getThinkingDelta(event) });
|
|
416
|
+
} else if (isToolExecutionStartEvent(event)) {
|
|
417
|
+
const { toolCallId, toolName, args } = getToolExecutionStart(event);
|
|
418
|
+
send({
|
|
419
|
+
type: 'tool_start',
|
|
420
|
+
toolCallId,
|
|
421
|
+
toolName,
|
|
422
|
+
args,
|
|
423
|
+
});
|
|
424
|
+
} else if (isToolExecutionEndEvent(event)) {
|
|
425
|
+
const { toolCallId, output, isError } = getToolExecutionEnd(event);
|
|
426
|
+
metrics.recordToolExecution();
|
|
427
|
+
send({
|
|
428
|
+
type: 'tool_end',
|
|
429
|
+
toolCallId,
|
|
430
|
+
output,
|
|
431
|
+
isError,
|
|
432
|
+
});
|
|
433
|
+
} else if (isTurnEndEvent(event)) {
|
|
434
|
+
stepCount++;
|
|
435
|
+
|
|
436
|
+
// Record token usage if available from the turn
|
|
437
|
+
const turnUsage = (event as any).usage;
|
|
438
|
+
if (turnUsage && typeof turnUsage.inputTokens === 'number') {
|
|
439
|
+
metrics.recordTokens(turnUsage.inputTokens, turnUsage.outputTokens ?? 0);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Track tool calls for loop detection (lock-free batch update).
|
|
443
|
+
// Deterministic JSON.stringify ensures consistent input comparison.
|
|
444
|
+
const { toolResults } = getTurnEndData(event);
|
|
445
|
+
if (Array.isArray(toolResults) && toolResults.length > 0) {
|
|
446
|
+
const newEntries = toolResults.map(tr => ({
|
|
447
|
+
tool: tr.toolName ?? 'unknown',
|
|
448
|
+
input: JSON.stringify(tr.content, null, 0), // Deterministic (no whitespace)
|
|
449
|
+
}));
|
|
450
|
+
stepHistory.push(...newEntries);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Loop detection: same tool + same args 3 times in a row.
|
|
454
|
+
// Only trigger if we have 3+ history entries (prevent false positives on first turn).
|
|
455
|
+
const LOOP_DETECTION_THRESHOLD = 3;
|
|
456
|
+
if (loopCooldown > 0) {
|
|
457
|
+
loopCooldown--;
|
|
458
|
+
} else if (stepHistory.length >= LOOP_DETECTION_THRESHOLD) {
|
|
459
|
+
const lastN = stepHistory.slice(-LOOP_DETECTION_THRESHOLD);
|
|
460
|
+
if (lastN.every(s => s.tool === lastN[0].tool && s.input === lastN[0].input)) {
|
|
461
|
+
loopCooldown = 3;
|
|
462
|
+
// TODO (metrics): Track loop detection rate — metrics.increment('agent.loop_detected', { model: modelName })
|
|
463
|
+
agent.steer({
|
|
464
|
+
role: 'user',
|
|
465
|
+
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.',
|
|
466
|
+
timestamp: Date.now(),
|
|
467
|
+
} as any);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Step limit enforcement
|
|
472
|
+
if (stepCount >= stepLimit) {
|
|
473
|
+
agent.abort();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
console.log(`[ask] Step ${stepCount}/${stepLimit}`);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
agent.prompt(lastUserContent).then(() => {
|
|
481
|
+
metrics.recordRequest(Date.now() - requestStartTime);
|
|
482
|
+
send({ type: 'done' });
|
|
483
|
+
controller.close();
|
|
484
|
+
}).catch((err) => {
|
|
485
|
+
metrics.recordRequest(Date.now() - requestStartTime);
|
|
486
|
+
metrics.recordError();
|
|
487
|
+
send({ type: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
488
|
+
controller.close();
|
|
489
|
+
});
|
|
332
490
|
},
|
|
333
491
|
});
|
|
334
492
|
|
|
335
|
-
return
|
|
493
|
+
return new Response(stream, {
|
|
494
|
+
headers: {
|
|
495
|
+
'Content-Type': 'text/event-stream',
|
|
496
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
497
|
+
'Connection': 'keep-alive',
|
|
498
|
+
'X-Accel-Buffering': 'no',
|
|
499
|
+
},
|
|
500
|
+
});
|
|
336
501
|
} catch (err) {
|
|
337
502
|
console.error('[ask] Failed to initialize model:', err);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
);
|
|
503
|
+
if (err instanceof MindOSError) {
|
|
504
|
+
return apiError(err.code, err.message);
|
|
505
|
+
}
|
|
506
|
+
return apiError(ErrorCodes.MODEL_INIT_FAILED, err instanceof Error ? err.message : 'Failed to initialize AI model', 500);
|
|
342
507
|
}
|
|
343
508
|
}
|