@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.
Files changed (46) hide show
  1. package/app/app/api/ask/route.ts +343 -178
  2. package/app/app/api/monitoring/route.ts +95 -0
  3. package/app/components/SettingsModal.tsx +58 -58
  4. package/app/components/settings/AgentsTab.tsx +240 -0
  5. package/app/components/settings/AiTab.tsx +4 -25
  6. package/app/components/settings/AppearanceTab.tsx +31 -13
  7. package/app/components/settings/KnowledgeTab.tsx +13 -28
  8. package/app/components/settings/McpAgentInstall.tsx +227 -0
  9. package/app/components/settings/McpServerStatus.tsx +172 -0
  10. package/app/components/settings/McpSkillsSection.tsx +583 -0
  11. package/app/components/settings/McpTab.tsx +17 -959
  12. package/app/components/settings/MonitoringTab.tsx +202 -0
  13. package/app/components/settings/PluginsTab.tsx +4 -27
  14. package/app/components/settings/Primitives.tsx +69 -0
  15. package/app/components/settings/ShortcutsTab.tsx +2 -4
  16. package/app/components/settings/SyncTab.tsx +8 -24
  17. package/app/components/settings/types.ts +116 -2
  18. package/app/instrumentation.ts +7 -2
  19. package/app/lib/agent/context.ts +151 -87
  20. package/app/lib/agent/index.ts +5 -3
  21. package/app/lib/agent/log.ts +1 -0
  22. package/app/lib/agent/model.ts +76 -10
  23. package/app/lib/agent/skill-rules.ts +70 -0
  24. package/app/lib/agent/stream-consumer.ts +73 -77
  25. package/app/lib/agent/to-agent-messages.ts +106 -0
  26. package/app/lib/agent/tools.ts +260 -266
  27. package/app/lib/api.ts +12 -3
  28. package/app/lib/core/csv.ts +2 -1
  29. package/app/lib/core/fs-ops.ts +7 -6
  30. package/app/lib/core/index.ts +1 -1
  31. package/app/lib/core/lines.ts +7 -6
  32. package/app/lib/core/search-index.ts +174 -0
  33. package/app/lib/core/search.ts +30 -1
  34. package/app/lib/core/security.ts +6 -3
  35. package/app/lib/errors.ts +108 -0
  36. package/app/lib/fs.ts +6 -3
  37. package/app/lib/i18n-en.ts +523 -0
  38. package/app/lib/i18n-zh.ts +548 -0
  39. package/app/lib/i18n.ts +4 -963
  40. package/app/lib/metrics.ts +81 -0
  41. package/app/next-env.d.ts +1 -1
  42. package/app/next.config.ts +1 -1
  43. package/app/package-lock.json +3258 -3093
  44. package/app/package.json +6 -3
  45. package/bin/cli.js +7 -4
  46. package/package.json +4 -1
@@ -1,107 +1,142 @@
1
1
  export const dynamic = 'force-dynamic';
2
- import { streamText, stepCountIs, type ModelMessage } from 'ai';
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 { getModel, knowledgeBaseTools, truncate, AGENT_SYSTEM_PROMPT, estimateTokens, estimateStringTokens, getContextLimit, needsCompact, truncateToolOutputs, compactMessages, hardPrune } from '@/lib/agent';
8
- import { effectiveAiConfig, readSettings } from '@/lib/settings';
9
- import type { Message as FrontendMessage, ToolCallPart as FrontendToolCallPart } from '@/lib/types';
10
-
11
- /**
12
- * Convert frontend Message[] (with parts containing tool calls + results)
13
- * into AI SDK ModelMessage[] that streamText expects.
14
- *
15
- * Frontend format:
16
- * { role: 'assistant', content: '...', parts: [TextPart, ToolCallPart(with output/state)] }
17
- *
18
- * AI SDK format:
19
- * { role: 'assistant', content: [TextPart, ToolCallPart(no output)] }
20
- * { role: 'tool', content: [ToolResultPart] } // one per completed tool call
21
- */
22
- function convertToModelMessages(messages: FrontendMessage[]): ModelMessage[] {
23
- const result: ModelMessage[] = [];
24
-
25
- for (const msg of messages) {
26
- if (msg.role === 'user') {
27
- result.push({ role: 'user', content: msg.content });
28
- continue;
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
- // Skip error placeholder messages from frontend
32
- if (msg.content.startsWith('__error__')) continue;
43
+ function getTextDelta(e: AgentEvent): string {
44
+ return (e as any).assistantMessageEvent?.delta ?? '';
45
+ }
33
46
 
34
- // Assistant message
35
- if (!msg.parts || msg.parts.length === 0) {
36
- // Plain text assistant message — no tool calls
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
- // Build assistant message content array (text parts + tool call parts)
44
- const assistantContent: Array<
45
- { type: 'text'; text: string } |
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
- if (assistantContent.length > 0) {
71
- result.push({ role: 'assistant', content: assistantContent });
72
- }
55
+ function isToolExecutionStartEvent(e: AgentEvent): boolean {
56
+ return e.type === 'tool_execution_start';
57
+ }
73
58
 
74
- // Add tool result messages for completed tool calls
75
- if (completedToolCalls.length > 0) {
76
- result.push({
77
- role: 'tool',
78
- content: completedToolCalls.map(tc => ({
79
- type: 'tool-result' as const,
80
- toolCallId: tc.toolCallId,
81
- toolName: tc.toolName,
82
- output: { type: 'text' as const, value: tc.output ?? '' },
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
- return result;
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 readKnowledgeFile(filePath: string): { ok: boolean; content: string; error?: string } {
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
- return { ok: true, content: truncate(getFileContent(filePath)) };
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 { ok: false, content: '', error: err instanceof Error ? err.message : String(err) };
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
- return { ok: true, content: truncate(raw) };
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 { ok: false, content: '', error: err instanceof Error ? err.message : String(err) };
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 NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
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
- const skillPath = path.resolve(process.cwd(), 'data/skills/mindos/SKILL.md');
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 when everything loads fine, a single summary line suffices.
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 go into a SEPARATE top-level section so the Agent
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 = getModel();
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
- // 3. Hard prune if still >90% context limit
273
- modelMessages = hardPrune(modelMessages, systemPrompt, modelName);
311
+ // Convert frontend messages to AgentMessage[]
312
+ const agentMessages = toAgentMessages(messages);
274
313
 
275
- // Phase 2: Step monitoring + loop detection
276
- const stepHistory: Array<{ tool: string; input: string }> = [];
277
- let loopDetected = false;
278
- let loopCooldown = 0; // skip detection for N steps after warning
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
- onStepFinish: ({ toolCalls, usage }) => {
295
- if (toolCalls) {
296
- for (const tc of toolCalls) {
297
- stepHistory.push({ tool: tc.toolName, input: JSON.stringify(tc.input) });
298
- }
299
- }
300
- // Loop detection: same tool + same args 3 times in a row
301
- // Skip detection during cooldown to avoid repeated warnings
302
- if (loopCooldown > 0) {
303
- loopCooldown--;
304
- } else if (stepHistory.length >= 3) {
305
- const last3 = stepHistory.slice(-3);
306
- if (last3.every(s => s.tool === last3[0].tool && s.input === last3[0].input)) {
307
- loopDetected = true;
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
- console.log(`[ask] Step ${stepHistory.length}/${stepLimit}, tokens=${usage?.totalTokens ?? '?'}`);
371
+ return undefined;
311
372
  },
312
373
 
313
- prepareStep: ({ messages: stepMessages }) => {
314
- if (loopDetected) {
315
- loopDetected = false;
316
- loopCooldown = 3; // suppress re-detection for 3 steps
317
- return {
318
- messages: [
319
- ...stepMessages,
320
- {
321
- role: 'user' as const,
322
- 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.',
323
- },
324
- ],
325
- };
326
- }
327
- return {}; // no modification
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
- onError: ({ error }) => {
331
- console.error('[ask] Stream error:', error);
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 result.toUIMessageStreamResponse();
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
- return NextResponse.json(
339
- { error: err instanceof Error ? err.message : 'Failed to initialize AI model' },
340
- { status: 500 },
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
  }