neoagent 2.4.1-beta.35 → 2.4.1-beta.37

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.
@@ -1,5 +1,5 @@
1
1
  const OpenAI = require('openai');
2
- const { BaseProvider } = require('./base');
2
+ const { OpenAICompatibleProvider } = require('./openaiCompatible');
3
3
 
4
4
  const NVIDIA_BASE_URL = 'https://integrate.api.nvidia.com/v1';
5
5
 
@@ -21,7 +21,7 @@ const REASONING_MODELS = new Set([
21
21
  'qwen/qwq-32b',
22
22
  ]);
23
23
 
24
- class NvidiaProvider extends BaseProvider {
24
+ class NvidiaProvider extends OpenAICompatibleProvider {
25
25
  constructor(config = {}) {
26
26
  super(config);
27
27
  this.name = 'nvidia';
@@ -68,7 +68,7 @@ class NvidiaProvider extends BaseProvider {
68
68
  } catch (err) {
69
69
  throw new Error(`NVIDIA NIM request failed: ${err?.message || String(err)}`);
70
70
  }
71
- return this._normalizeResponse(response);
71
+ return this.normalizeResponse(response);
72
72
  }
73
73
 
74
74
  async *stream(messages, tools = [], options = {}) {
@@ -92,7 +92,7 @@ class NvidiaProvider extends BaseProvider {
92
92
 
93
93
  for await (const chunk of stream) {
94
94
  if (chunk.usage && (!chunk.choices || chunk.choices.length === 0)) {
95
- finalUsage = this._normalizeUsage(chunk.usage);
95
+ finalUsage = this.normalizeUsage(chunk.usage);
96
96
  continue;
97
97
  }
98
98
 
@@ -121,11 +121,11 @@ class NvidiaProvider extends BaseProvider {
121
121
 
122
122
  const finishReason = chunk.choices[0]?.finish_reason;
123
123
  if (finishReason === 'tool_calls' || (finishReason === 'stop' && toolCalls.length > 0)) {
124
- yield { type: 'tool_calls', toolCalls, content, usage: this._normalizeUsage(chunk.usage) || finalUsage };
124
+ yield { type: 'tool_calls', toolCalls, content, usage: this.normalizeUsage(chunk.usage) || finalUsage };
125
125
  return;
126
126
  }
127
127
  if (finishReason === 'stop') {
128
- yield { type: 'done', content, usage: this._normalizeUsage(chunk.usage) || finalUsage };
128
+ yield { type: 'done', content, usage: this.normalizeUsage(chunk.usage) || finalUsage };
129
129
  return;
130
130
  }
131
131
  }
@@ -137,29 +137,6 @@ class NvidiaProvider extends BaseProvider {
137
137
  }
138
138
  }
139
139
 
140
- _normalizeResponse(response) {
141
- const choice = response.choices[0];
142
- const msg = choice.message;
143
- return {
144
- content: msg.content || '',
145
- toolCalls: msg.tool_calls?.map((tc) => ({
146
- id: tc.id,
147
- type: 'function',
148
- function: { name: tc.function.name, arguments: tc.function.arguments },
149
- })) || [],
150
- finishReason: choice.finish_reason,
151
- usage: this._normalizeUsage(response.usage),
152
- };
153
- }
154
-
155
- _normalizeUsage(usage) {
156
- if (!usage) return null;
157
- return {
158
- promptTokens: usage.prompt_tokens ?? 0,
159
- completionTokens: usage.completion_tokens ?? 0,
160
- totalTokens: usage.total_tokens ?? 0,
161
- };
162
- }
163
140
  }
164
141
 
165
142
  module.exports = { NvidiaProvider };
@@ -81,33 +81,65 @@ class OllamaProvider extends BaseProvider {
81
81
  }));
82
82
  }
83
83
 
84
- async chat(messages, tools = [], options = {}) {
85
- const model = options.model || this.config.model || 'llama3.1';
86
- await this.ensureModel(model);
84
+ buildChatBody(messages, tools, options, stream) {
87
85
  const body = {
88
- model,
86
+ model: options.model || this.config.model || 'llama3.1',
89
87
  messages: messages.map(m => ({
90
88
  role: m.role,
91
89
  content: m.content || '',
92
90
  ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
93
91
  ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {})
94
92
  })),
95
- stream: false,
93
+ stream,
96
94
  options: {
97
95
  temperature: options.temperature ?? 0.7,
98
96
  num_predict: options.maxTokens || 16384
99
97
  }
100
98
  };
101
-
102
99
  if (tools.length > 0) {
103
100
  body.tools = this.formatToolsForOllama(tools);
104
101
  }
102
+ return body;
103
+ }
105
104
 
105
+ // Ollama returns HTTP 200 with an error body for some failures and a non-2xx
106
+ // status for others; surface both as real errors instead of letting callers
107
+ // see a silently empty response. Tags models that reject tools so the caller
108
+ // can transparently retry without them.
109
+ async postChat(body) {
106
110
  const res = await fetch(`${this.baseUrl}/api/chat`, {
107
111
  method: 'POST',
108
112
  headers: { 'Content-Type': 'application/json' },
109
113
  body: JSON.stringify(body)
110
114
  });
115
+ if (!res.ok) {
116
+ const detail = await res.text().catch(() => '');
117
+ let message = detail;
118
+ try { message = JSON.parse(detail)?.error || detail; } catch {}
119
+ const err = new Error(`Ollama /api/chat failed (HTTP ${res.status}): ${message || res.statusText}`);
120
+ if (/does not support tools|tools.*not supported/i.test(message)) {
121
+ err.code = 'OLLAMA_TOOLS_UNSUPPORTED';
122
+ }
123
+ throw err;
124
+ }
125
+ return res;
126
+ }
127
+
128
+ async chat(messages, tools = [], options = {}) {
129
+ const model = options.model || this.config.model || 'llama3.1';
130
+ await this.ensureModel(model);
131
+
132
+ let res;
133
+ try {
134
+ res = await this.postChat(this.buildChatBody(messages, tools, { ...options, model }, false));
135
+ } catch (err) {
136
+ if (err.code === 'OLLAMA_TOOLS_UNSUPPORTED' && tools.length > 0) {
137
+ console.warn(`[Ollama] Model '${model}' does not support tools; retrying without them.`);
138
+ res = await this.postChat(this.buildChatBody(messages, [], { ...options, model }, false));
139
+ } else {
140
+ throw err;
141
+ }
142
+ }
111
143
 
112
144
  const data = await res.json();
113
145
  const msg = data.message || {};
@@ -135,31 +167,19 @@ class OllamaProvider extends BaseProvider {
135
167
  async *stream(messages, tools = [], options = {}) {
136
168
  const model = options.model || this.config.model || 'llama3.1';
137
169
  await this.ensureModel(model);
138
- const body = {
139
- model,
140
- messages: messages.map(m => ({
141
- role: m.role,
142
- content: m.content || '',
143
- ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
144
- ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {})
145
- })),
146
- stream: true,
147
- options: {
148
- temperature: options.temperature ?? 0.7,
149
- num_predict: options.maxTokens || 16384
150
- }
151
- };
152
170
 
153
- if (tools.length > 0) {
154
- body.tools = this.formatToolsForOllama(tools);
171
+ let res;
172
+ try {
173
+ res = await this.postChat(this.buildChatBody(messages, tools, { ...options, model }, true));
174
+ } catch (err) {
175
+ if (err.code === 'OLLAMA_TOOLS_UNSUPPORTED' && tools.length > 0) {
176
+ console.warn(`[Ollama] Model '${model}' does not support tools; retrying stream without them.`);
177
+ res = await this.postChat(this.buildChatBody(messages, [], { ...options, model }, true));
178
+ } else {
179
+ throw err;
180
+ }
155
181
  }
156
182
 
157
- const res = await fetch(`${this.baseUrl}/api/chat`, {
158
- method: 'POST',
159
- headers: { 'Content-Type': 'application/json' },
160
- body: JSON.stringify(body)
161
- });
162
-
163
183
  const reader = res.body.getReader();
164
184
  const decoder = new TextDecoder();
165
185
  let content = '';
@@ -1,7 +1,7 @@
1
1
  const OpenAI = require('openai');
2
- const { BaseProvider } = require('./base');
2
+ const { OpenAICompatibleProvider } = require('./openaiCompatible');
3
3
 
4
- class OpenAIProvider extends BaseProvider {
4
+ class OpenAIProvider extends OpenAICompatibleProvider {
5
5
  constructor(config = {}) {
6
6
  super(config);
7
7
  this.name = 'openai';
@@ -173,31 +173,6 @@ class OpenAIProvider extends BaseProvider {
173
173
  }
174
174
  }
175
175
 
176
- async analyzeImage(options = {}) {
177
- const model = options.model || this.getDefaultVisionModel();
178
- const b64 = BaseProvider.readImageAsBase64(options.imagePath);
179
- const response = await this.client.chat.completions.create({
180
- model,
181
- max_tokens: options.maxTokens || 4096,
182
- messages: [{
183
- role: 'user',
184
- content: [
185
- { type: 'text', text: options.question || 'Describe this image in detail.' },
186
- {
187
- type: 'image_url',
188
- image_url: {
189
- url: `data:${options.mimeType || 'image/jpeg'};base64,${b64}`
190
- }
191
- }
192
- ]
193
- }]
194
- });
195
-
196
- return {
197
- content: response.choices[0]?.message?.content || '',
198
- model: response.model || model,
199
- };
200
- }
201
176
  }
202
177
 
203
178
  module.exports = { OpenAIProvider };
@@ -0,0 +1,70 @@
1
+ const { BaseProvider } = require('./base');
2
+
3
+ // Shared base for providers that speak the OpenAI Chat Completions wire format
4
+ // (OpenAI, Grok, NVIDIA NIM, GitHub Copilot, ...). It owns the response/usage
5
+ // normalization and vision request that were previously copy-pasted into each
6
+ // provider. Per-provider concerns — client construction, model lists, context
7
+ // windows, reasoning detection, and the streaming loop — stay in the subclasses,
8
+ // since those genuinely differ between vendors.
9
+ class OpenAICompatibleProvider extends BaseProvider {
10
+ normalizeUsage(usage) {
11
+ if (!usage) return null;
12
+ return {
13
+ promptTokens: usage.prompt_tokens ?? usage.promptTokens ?? 0,
14
+ completionTokens: usage.completion_tokens ?? usage.completionTokens ?? 0,
15
+ totalTokens: usage.total_tokens ?? usage.totalTokens ?? 0,
16
+ };
17
+ }
18
+
19
+ normalizeResponse(response) {
20
+ const choice = response?.choices?.[0];
21
+ if (!choice) {
22
+ throw new Error(`Provider '${this.name}' returned no choices in the response`);
23
+ }
24
+ const msg = choice.message || {};
25
+ return {
26
+ content: msg.content || '',
27
+ toolCalls: (msg.tool_calls || [])
28
+ .filter((tc) => tc?.function)
29
+ .map((tc) => ({
30
+ id: tc.id,
31
+ type: 'function',
32
+ function: { name: tc.function.name, arguments: tc.function.arguments },
33
+ })),
34
+ finishReason: choice.finish_reason,
35
+ usage: this.normalizeUsage(response.usage),
36
+ };
37
+ }
38
+
39
+ async analyzeImage(options = {}) {
40
+ if (!this.supportsVision()) {
41
+ throw new Error(`Provider '${this.name}' does not support image analysis`);
42
+ }
43
+
44
+ const model = options.model || this.getDefaultVisionModel();
45
+ const b64 = BaseProvider.readImageAsBase64(options.imagePath);
46
+ const response = await this.client.chat.completions.create({
47
+ model,
48
+ max_tokens: options.maxTokens || 4096,
49
+ messages: [{
50
+ role: 'user',
51
+ content: [
52
+ { type: 'text', text: options.question || 'Describe this image in detail.' },
53
+ {
54
+ type: 'image_url',
55
+ image_url: {
56
+ url: `data:${options.mimeType || 'image/jpeg'};base64,${b64}`,
57
+ },
58
+ },
59
+ ],
60
+ }],
61
+ });
62
+
63
+ return {
64
+ content: response.choices[0]?.message?.content || '',
65
+ model: response.model || model,
66
+ };
67
+ }
68
+ }
69
+
70
+ module.exports = { OpenAICompatibleProvider };
@@ -561,10 +561,13 @@ function buildExecutionGuidance({ analysis, plan = null, capabilityHealth }) {
561
561
  return lines.filter(Boolean).join('\n\n');
562
562
  }
563
563
 
564
- function buildVerifierPrompt({ analysis, toolExecutionSummary, evidenceSources, finalReply }) {
564
+ function buildVerifierPrompt({ analysis, tools = [], toolExecutionSummary, evidenceSources, finalReply }) {
565
+ const toolNames = summarizeTools(tools).join(', ');
565
566
  return composeJsonPrompt([
566
567
  JSON_ONLY_RESPONSE_RULE,
567
568
  ...VERIFIER_PROMPT_INSTRUCTIONS,
569
+ formatAvailableToolsLine(toolNames),
570
+ 'Do not claim a tool or capability was unavailable if it is listed as available in this run. A missing tool execution is missing evidence, not proof of missing tool exposure.',
568
571
  `Freshness risk: ${analysis.freshness_risk}`,
569
572
  `Verification need: ${analysis.verification_need}`,
570
573
  `Completion confidence required: ${analysis.completion_confidence_required || 'medium'}`,
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ // Classifies tool executions into run evidence (what changed, what failed, what
4
+ // is relevant to the user's answer) and builds the deterministic recovery
5
+ // context the engine feeds back to the model after a failure. Kept free of
6
+ // engine state so the classification rules are pure and unit testable.
7
+
8
+ const { compactToolResult } = require('./toolResult');
9
+ const { summarizeForLog } = require('./logFormat');
10
+ const { normalizeOutgoingMessage, clampRunContext } = require('./messagingFallback');
11
+
12
+ // Ordered classification rules mapping a tool name to its evidence "source"
13
+ // bucket. First matching rule wins, so order is significant. Declared as data
14
+ // rather than a nested ternary so new tool families can be slotted in by adding
15
+ // a row instead of editing control flow.
16
+ const EVIDENCE_SOURCE_RULES = [
17
+ { source: 'browser', match: (name) => name.startsWith('browser_') },
18
+ { source: 'android', match: (name) => name.startsWith('android_') },
19
+ { source: 'mcp', match: (name) => name.startsWith('mcp_') },
20
+ { source: 'memory', match: (name) => name.startsWith('memory_') || name === 'session_search' },
21
+ { source: 'search', match: (name) => name === 'web_search' },
22
+ { source: 'http', match: (name) => name === 'http_request' },
23
+ { source: 'files', match: (name) => ['read_file', 'search_files', 'list_directory', 'write_file', 'edit_file'].includes(name) },
24
+ { source: 'command', match: (name) => name === 'execute_command' },
25
+ { source: 'skills', match: (name) => name.includes('skill') },
26
+ { source: 'tasks', match: (name) => name === 'create_task' || name === 'update_task' || name === 'delete_task' || name === 'list_tasks' || name.includes('widget') },
27
+ { source: 'messaging', match: (name) => name === 'send_message' || name === 'make_call' },
28
+ { source: 'data', match: (name) => name.startsWith('recordings_') || name === 'read_health_data' },
29
+ { source: 'vision', match: (name) => name === 'analyze_image' },
30
+ { source: 'subagent', match: (name) => name.includes('subagent') },
31
+ ];
32
+
33
+ function deriveEvidenceSource(name) {
34
+ const rule = EVIDENCE_SOURCE_RULES.find((entry) => entry.match(name));
35
+ return rule ? rule.source : 'tool';
36
+ }
37
+
38
+ function classifyToolExecution(toolName, toolArgs = {}, result, errorMessage = '') {
39
+ const name = String(toolName || '');
40
+ const evidenceRelevantPrefixes = ['browser_', 'android_'];
41
+ const evidenceRelevantExact = new Set([
42
+ 'web_search',
43
+ 'http_request',
44
+ 'read_file',
45
+ 'search_files',
46
+ 'list_directory',
47
+ 'session_search',
48
+ 'memory_recall',
49
+ 'analyze_image',
50
+ 'read_health_data',
51
+ 'recordings_list',
52
+ 'recordings_get',
53
+ 'recordings_search',
54
+ 'list_tasks',
55
+ 'wait_subagent',
56
+ ]);
57
+ const stateChangingExact = new Set([
58
+ 'execute_command',
59
+ 'write_file',
60
+ 'edit_file',
61
+ 'send_interim_update',
62
+ 'send_message',
63
+ 'make_call',
64
+ 'create_skill',
65
+ 'update_skill',
66
+ 'delete_skill',
67
+ 'create_task',
68
+ 'update_task',
69
+ 'delete_task',
70
+ 'create_ai_widget',
71
+ 'update_ai_widget',
72
+ 'delete_ai_widget',
73
+ 'save_widget_snapshot',
74
+ 'mcp_add_server',
75
+ 'mcp_remove_server',
76
+ 'spawn_subagent',
77
+ 'cancel_subagent',
78
+ ]);
79
+
80
+ const evidenceSource = deriveEvidenceSource(name);
81
+
82
+ const evidenceRelevant = evidenceRelevantExact.has(name)
83
+ || evidenceRelevantPrefixes.some((prefix) => name.startsWith(prefix));
84
+ const stateChanged = stateChangingExact.has(name)
85
+ || name.startsWith('android_')
86
+ || ['browser_click', 'browser_type', 'browser_evaluate'].includes(name);
87
+
88
+ let normalizedError = String(errorMessage || result?.error || '').trim();
89
+ if (!normalizedError && name === 'execute_command' && result && typeof result === 'object') {
90
+ if (result.timedOut) {
91
+ normalizedError = `Command timed out after ${result.durationMs || 'unknown'} ms`;
92
+ } else if (result.killed || result.signal) {
93
+ normalizedError = 'Command was killed before it finished';
94
+ } else if (typeof result.exitCode === 'number' && result.exitCode !== 0) {
95
+ normalizedError = summarizeForLog(result.stderr || result.stdout || `Command exited with code ${result.exitCode}`, 220);
96
+ }
97
+ }
98
+
99
+ if (!normalizedError && result && typeof result === 'object') {
100
+ const nestedResult = result.result && typeof result.result === 'object' && !Array.isArray(result.result)
101
+ ? result.result
102
+ : null;
103
+ const detail = normalizeOutgoingMessage(
104
+ result.reason
105
+ || result.message
106
+ || nestedResult?.reason
107
+ || nestedResult?.message
108
+ || ''
109
+ );
110
+
111
+ if (result.skipped === true || nestedResult?.skipped === true) {
112
+ normalizedError = detail || 'Tool reported skipped outcome.';
113
+ } else if (result.success === false || nestedResult?.success === false) {
114
+ normalizedError = detail || 'Tool reported success=false.';
115
+ } else if (result.sent === false || nestedResult?.sent === false) {
116
+ normalizedError = detail || 'Tool reported sent=false.';
117
+ }
118
+ }
119
+
120
+ return {
121
+ toolName: name,
122
+ ok: !normalizedError,
123
+ error: normalizedError,
124
+ evidenceSource,
125
+ evidenceRelevant,
126
+ stateChanged,
127
+ dependsOnOutput: true,
128
+ summary: compactToolResult(name, toolArgs, result || { error: errorMessage || 'Tool failed' }, {
129
+ softLimit: 500,
130
+ hardLimit: 900,
131
+ }),
132
+ };
133
+ }
134
+
135
+ function summarizeToolExecutions(toolExecutions = [], maxItems = 10) {
136
+ return toolExecutions.slice(-maxItems).map((item, index) => {
137
+ const status = item.ok ? 'ok' : `error=${item.error}`;
138
+ return `${index + 1}. ${item.toolName} [${item.evidenceSource}] ${status} :: ${clampRunContext(item.summary || '', 220)}`;
139
+ }).join('\n');
140
+ }
141
+
142
+ function summarizeAvailableTools(tools = [], { exclude = [] } = {}) {
143
+ const excluded = new Set((Array.isArray(exclude) ? exclude : [exclude]).filter(Boolean));
144
+ return tools
145
+ .map((tool) => String(tool?.name || '').trim())
146
+ .filter((name) => name && !excluded.has(name))
147
+ .slice(0, 24)
148
+ .join(', ');
149
+ }
150
+
151
+ function inferToolFailureMessage(toolName, result) {
152
+ const explicitError = normalizeOutgoingMessage(result?.error || '');
153
+ if (explicitError) return explicitError;
154
+
155
+ if (!result || typeof result !== 'object') return '';
156
+
157
+ if (toolName === 'execute_command') {
158
+ if (result.timedOut) {
159
+ return `Command timed out after ${result.durationMs || 'unknown'} ms`;
160
+ }
161
+ if (result.killed || result.signal) {
162
+ return 'Command was killed before it finished';
163
+ }
164
+ if (typeof result.exitCode === 'number' && result.exitCode !== 0) {
165
+ return summarizeForLog(result.stderr || result.stdout || `Command exited with code ${result.exitCode}`, 220);
166
+ }
167
+ }
168
+
169
+ if (toolName === 'http_request' && typeof result.status === 'number' && result.status >= 400) {
170
+ const bodySnippet = normalizeOutgoingMessage(result.body || '');
171
+ return summarizeForLog(
172
+ bodySnippet
173
+ ? `HTTP request returned status ${result.status}: ${bodySnippet}`
174
+ : `HTTP request returned status ${result.status}`,
175
+ 240
176
+ );
177
+ }
178
+
179
+ return '';
180
+ }
181
+
182
+ function buildAutonomousRecoveryContext({ err, toolExecutions = [], tools = [], userMessage, visibleMessageSent = false }) {
183
+ const lastFailure = [...toolExecutions].reverse().find((item) => !item.ok);
184
+ const alternativeTools = summarizeAvailableTools(tools, { exclude: lastFailure?.toolName || null });
185
+ const parts = [
186
+ 'This is an internal recovery retry for the same user task. Continue the task instead of stopping.',
187
+ userMessage ? `Original task: ${clampRunContext(userMessage, 260)}` : '',
188
+ lastFailure?.toolName ? `Previous attempt failed on tool: ${lastFailure.toolName}.` : '',
189
+ lastFailure?.error ? `Concrete failure: ${summarizeForLog(lastFailure.error, 260)}.` : '',
190
+ err?.message ? `Run-level error after that failure: ${summarizeForLog(err.message, 220)}.` : '',
191
+ 'Do not send a blocker message just because one tool path failed.',
192
+ 'Use a different safe approach if available: alternate tool, different query, browser path, HTTP fetch, file/code inspection, or command verification.',
193
+ visibleMessageSent ? 'A user-facing message was already sent in a previous internal attempt. Continue silently unless you have a materially new finished result or a real external blocker.' : '',
194
+ alternativeTools ? `Other available tools in this run: ${alternativeTools}.` : '',
195
+ 'Only stop if the remaining problem truly requires an external dependency or user action outside this run.'
196
+ ];
197
+ return parts.filter(Boolean).join(' ');
198
+ }
199
+
200
+ module.exports = {
201
+ classifyToolExecution,
202
+ deriveEvidenceSource,
203
+ summarizeToolExecutions,
204
+ summarizeAvailableTools,
205
+ inferToolFailureMessage,
206
+ buildAutonomousRecoveryContext,
207
+ };
@@ -14,9 +14,16 @@
14
14
 
15
15
  const MCP_ALWAYS_INCLUDE_THRESHOLD = 20;
16
16
  const MAX_TOOLS = 128; // Strict provider limit (e.g. Github Copilot / OpenAI)
17
+ const ALWAYS_INCLUDE_BUILT_INS = [
18
+ 'send_message',
19
+ 'create_task',
20
+ 'list_tasks',
21
+ 'delete_task',
22
+ 'update_task',
23
+ ];
17
24
 
18
25
  function ensureRequiredTools(selectedTools = [], builtInTools = [], options = {}) {
19
- const requiredNames = [];
26
+ const requiredNames = [...ALWAYS_INCLUDE_BUILT_INS];
20
27
  if (options.widgetId) requiredNames.push('save_widget_snapshot');
21
28
  if (!requiredNames.length) return selectedTools;
22
29
 
@@ -21,6 +21,7 @@ const {
21
21
  } = require('./intelligence');
22
22
  const { AGENT_DATA_DIR } = require('../../../runtime/paths');
23
23
  const { isMainAgent, resolveAgentId } = require('../agents/manager');
24
+ const { buildFtsQuery } = require('../../db/ftsQuery');
24
25
  const {
25
26
  decryptLocalValue,
26
27
  encryptLocalValue,
@@ -187,13 +188,6 @@ function parseStringSetting(value) {
187
188
  }
188
189
  }
189
190
 
190
- function buildFtsQuery(query) {
191
- const tokens = String(query || '')
192
- .match(/[\p{L}\p{N}_-]{2,}/gu) || [];
193
- if (!tokens.length) return null;
194
- return tokens.map((token) => `${token.replace(/"/g, '')}*`).join(' AND ');
195
- }
196
-
197
191
  function stripHighlight(text) {
198
192
  return String(text || '').replace(/<\/?mark>/g, '');
199
193
  }
@@ -317,7 +317,7 @@ function setupWebSocket(io, services) {
317
317
  const data = asObject(raw);
318
318
  const runId = toOptionalString(data?.runId, 128);
319
319
  console.warn(`[WS] agent:abort received from user ${userId} for run ${runId || 'unknown'}`);
320
- agentEngine.abort(runId || null);
320
+ agentEngine.abort(runId || null, { userId });
321
321
  socket.emit('agent:aborted', { runId: runId || null });
322
322
  } catch (err) {
323
323
  console.error(`[WS] agent:abort failed for user ${userId}:`, err);
@@ -366,8 +366,14 @@ function setupWebSocket(io, services) {
366
366
  }
367
367
  console.log(`[WS] agent:run_detail requested by user ${userId} run=${runId}`);
368
368
  const run = db.prepare('SELECT * FROM agent_runs WHERE id = ? AND user_id = ?').get(runId, userId);
369
+ if (!run) {
370
+ // Don't leak steps/history/events for a run the user doesn't own:
371
+ // agent_steps and run events are keyed only by run_id, so the run
372
+ // ownership check is the sole authorization gate.
373
+ return socket.emit('agent:run_detail', { run: null, steps: [], history: [], events: [] });
374
+ }
369
375
  const steps = db.prepare('SELECT * FROM agent_steps WHERE run_id = ? ORDER BY step_index ASC').all(runId);
370
- const history = db.prepare('SELECT * FROM conversation_history WHERE agent_run_id = ? ORDER BY created_at ASC').all(runId);
376
+ const history = db.prepare('SELECT * FROM conversation_history WHERE agent_run_id = ? AND user_id = ? ORDER BY created_at ASC').all(runId, userId);
371
377
  const events = listRunEvents(runId);
372
378
  socket.emit('agent:run_detail', { run, steps, history, events });
373
379
  } catch (err) {