neoagent 2.5.2-beta.1 → 2.5.2-beta.10

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.
@@ -0,0 +1,356 @@
1
+ 'use strict';
2
+
3
+ const { v4: uuidv4 } = require('uuid');
4
+ const db = require('../../../db/database');
5
+ const { activateTools } = require('../toolSelector');
6
+ const { recordRunEvent } = require('../runEvents');
7
+ const { parseMaybeJson } = require('../logFormat');
8
+ const { mergeGoalContracts } = require('./completion_judge');
9
+ const { buildInitialProgressLedger } = require('./progress_monitor');
10
+ const {
11
+ createDeliveryState,
12
+ markInterimDelivered,
13
+ markFinalDelivered,
14
+ } = require('./delivery_state');
15
+
16
+ function isoNow() {
17
+ return new Date().toISOString();
18
+ }
19
+
20
+ function persistRunMetadata(_engine, runId, patch = {}) {
21
+ if (!runId || !patch || typeof patch !== 'object') return;
22
+ const existing = db.prepare('SELECT metadata_json FROM agent_runs WHERE id = ?').get(runId);
23
+ const current = parseMaybeJson(existing?.metadata_json, {}) || {};
24
+ const next = { ...current, ...patch };
25
+ db.prepare('UPDATE agent_runs SET metadata_json = ? WHERE id = ?')
26
+ .run(JSON.stringify(next), runId);
27
+ }
28
+
29
+ function updateRunGoalContract(engine, runId, patch = {}, options = {}) {
30
+ const runMeta = engine.getRunMeta(runId);
31
+ if (!runMeta) return null;
32
+ runMeta.goalContract = mergeGoalContracts(runMeta.goalContract, patch);
33
+ if (options.persist !== false) {
34
+ persistRunMetadata(engine, runId, {
35
+ goalContract: runMeta.goalContract,
36
+ });
37
+ }
38
+ return runMeta.goalContract;
39
+ }
40
+
41
+ function buildProgressLedgerSnapshot(_engine, runMeta) {
42
+ if (!runMeta?.progressLedger) return null;
43
+ return {
44
+ currentStep: runMeta.progressLedger.currentStep || null,
45
+ currentTool: runMeta.progressLedger.currentTool || null,
46
+ currentStepStartedAt: runMeta.progressLedger.currentStepStartedAt || null,
47
+ lastVerifiedProgressAt: runMeta.progressLedger.lastVerifiedProgressAt || null,
48
+ lastUserVisibleUpdateAt: runMeta.progressLedger.lastUserVisibleUpdateAt || null,
49
+ lastFinalDeliveryAt: runMeta.progressLedger.lastFinalDeliveryAt || null,
50
+ heartbeatCount: Number(runMeta.progressLedger.heartbeatCount || 0),
51
+ stallNotifiedAt: runMeta.progressLedger.stallNotifiedAt || null,
52
+ progressState: runMeta.progressLedger.progressState || 'active',
53
+ currentPhase: runMeta.progressLedger.currentPhase || 'idle',
54
+ };
55
+ }
56
+
57
+ function persistProgressLedger(engine, runId) {
58
+ const runMeta = engine.getRunMeta(runId);
59
+ if (!runMeta?.progressLedger) return;
60
+ persistRunMetadata(engine, runId, {
61
+ progressLedger: buildProgressLedgerSnapshot(engine, runMeta),
62
+ });
63
+ }
64
+
65
+ function updateRunProgress(engine, runId, patch = {}, options = {}) {
66
+ const runMeta = engine.getRunMeta(runId);
67
+ if (!runMeta) return null;
68
+ if (!runMeta.progressLedger) {
69
+ runMeta.progressLedger = buildInitialProgressLedger({
70
+ startedAt: runMeta.startedAtIso || isoNow(),
71
+ });
72
+ }
73
+
74
+ const previousState = runMeta.progressLedger.progressState || 'active';
75
+ runMeta.progressLedger = {
76
+ ...runMeta.progressLedger,
77
+ ...patch,
78
+ };
79
+
80
+ if (options.verified === true) {
81
+ runMeta.progressLedger.lastVerifiedProgressAt = options.timestamp || isoNow();
82
+ runMeta.progressLedger.progressState = 'active';
83
+ runMeta.progressLedger.stallNotifiedAt = null;
84
+ recordRunEventSafe(engine, runMeta.userId, runId, 'progress_verified', {
85
+ phase: runMeta.progressLedger.currentPhase || 'idle',
86
+ currentStep: runMeta.progressLedger.currentStep || null,
87
+ currentTool: runMeta.progressLedger.currentTool || null,
88
+ }, { agentId: runMeta.agentId, stepId: options.stepId || null });
89
+ if (previousState === 'stalled') {
90
+ recordRunEventSafe(engine, runMeta.userId, runId, 'progress_resumed', {
91
+ phase: runMeta.progressLedger.currentPhase || 'idle',
92
+ currentStep: runMeta.progressLedger.currentStep || null,
93
+ currentTool: runMeta.progressLedger.currentTool || null,
94
+ }, { agentId: runMeta.agentId, stepId: options.stepId || null });
95
+ }
96
+ }
97
+
98
+ if (options.persist !== false) {
99
+ persistProgressLedger(engine, runId);
100
+ }
101
+ return runMeta.progressLedger;
102
+ }
103
+
104
+ function markRunVisibleProgress(engine, runId, timestamp = isoNow()) {
105
+ const runMeta = engine.getRunMeta(runId);
106
+ if (!runMeta) return null;
107
+ if (!runMeta.deliveryState) runMeta.deliveryState = createDeliveryState();
108
+ markInterimDelivered(runMeta.deliveryState);
109
+ const ledger = updateRunProgress(engine, runId, {
110
+ lastUserVisibleUpdateAt: timestamp,
111
+ }, {
112
+ persist: false,
113
+ });
114
+ persistProgressLedger(engine, runId);
115
+ return ledger;
116
+ }
117
+
118
+ function markRunFinalDelivery(engine, runId, content = '', timestamp = isoNow()) {
119
+ const runMeta = engine.getRunMeta(runId);
120
+ if (!runMeta) return null;
121
+ if (!runMeta.deliveryState) runMeta.deliveryState = createDeliveryState();
122
+ markFinalDelivered(runMeta.deliveryState);
123
+ runMeta.messagingSent = true;
124
+ runMeta.finalDeliverySent = true;
125
+ runMeta.lastSentMessage = String(content || '').trim() || runMeta.lastSentMessage || '';
126
+ const ledger = updateRunProgress(engine, runId, {
127
+ lastUserVisibleUpdateAt: timestamp,
128
+ lastFinalDeliveryAt: timestamp,
129
+ progressState: 'complete',
130
+ }, {
131
+ persist: false,
132
+ });
133
+ persistProgressLedger(engine, runId);
134
+ return ledger;
135
+ }
136
+
137
+ function recordRunEventSafe(_engine, userId, runId, eventType, payload = {}, options = {}) {
138
+ try {
139
+ return recordRunEvent({
140
+ runId,
141
+ userId,
142
+ agentId: options.agentId || null,
143
+ eventType,
144
+ requestId: options.requestId || null,
145
+ stepId: options.stepId || null,
146
+ payload,
147
+ });
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ function initializeToolRuntime(engine, runId, allTools, initialTools, options = {}) {
154
+ const runMeta = engine.getRunMeta(runId);
155
+ if (!runMeta) return;
156
+ runMeta.toolCatalog = Array.isArray(allTools) ? allTools : [];
157
+ runMeta.activeTools = Array.isArray(initialTools) ? initialTools : [];
158
+ runMeta.toolSelectionOptions = {
159
+ widgetId: options.widgetId || null,
160
+ };
161
+ }
162
+
163
+ function getActiveTools(engine, runId) {
164
+ return engine.getRunMeta(runId)?.activeTools || [];
165
+ }
166
+
167
+ function activateToolsForRun(engine, runId, names = []) {
168
+ const runMeta = engine.getRunMeta(runId);
169
+ if (!runMeta) throw new Error('Run is not active.');
170
+ const result = activateTools(
171
+ runMeta.activeTools,
172
+ runMeta.toolCatalog,
173
+ names,
174
+ runMeta.toolSelectionOptions,
175
+ );
176
+ runMeta.activeTools = result.tools;
177
+ recordRunEventSafe(engine, runMeta.userId, runId, 'tools_activated', {
178
+ activated: result.activated,
179
+ evicted: result.evicted,
180
+ unknown: result.unknown,
181
+ notActivated: result.notActivated,
182
+ activeToolNames: result.tools.map((tool) => tool.name),
183
+ }, { agentId: runMeta.agentId });
184
+ return {
185
+ success: result.unknown.length === 0 && result.notActivated.length === 0,
186
+ activated: result.activated,
187
+ evicted: result.evicted,
188
+ unknown: result.unknown,
189
+ not_activated: result.notActivated,
190
+ active_tools: result.tools.map((tool) => tool.name),
191
+ };
192
+ }
193
+
194
+ function findActiveRunForUser(engine, userId, predicate = null) {
195
+ let candidate = null;
196
+ for (const [runId, runMeta] of engine.activeRuns.entries()) {
197
+ if (runMeta.userId !== userId || runMeta.aborted) continue;
198
+ if (typeof predicate === 'function' && !predicate(runMeta, runId)) continue;
199
+ if (!candidate || (runMeta.startedAt || 0) >= (candidate.startedAt || 0)) {
200
+ candidate = { runId, ...runMeta };
201
+ }
202
+ }
203
+ return candidate;
204
+ }
205
+
206
+ function findSteerableRunForUser(engine, userId, triggerSource = 'web') {
207
+ return findActiveRunForUser(
208
+ engine,
209
+ userId,
210
+ (runMeta) => runMeta.triggerSource === triggerSource && runMeta.triggerType === 'user'
211
+ );
212
+ }
213
+
214
+ function enqueueSteering(engine, runId, content, metadata = {}) {
215
+ const runMeta = engine.getRunMeta(runId);
216
+ const trimmed = typeof content === 'string' ? content.trim() : '';
217
+ if (!runMeta || runMeta.aborted || !trimmed) return null;
218
+
219
+ const item = {
220
+ id: uuidv4(),
221
+ content: trimmed,
222
+ metadata,
223
+ createdAt: isoNow(),
224
+ };
225
+
226
+ runMeta.steeringQueue.push(item);
227
+ engine.emit(runMeta.userId, 'run:steer_queued', {
228
+ runId,
229
+ content: item.content,
230
+ pendingCount: runMeta.steeringQueue.length,
231
+ });
232
+
233
+ return {
234
+ runId,
235
+ pendingCount: runMeta.steeringQueue.length,
236
+ item,
237
+ };
238
+ }
239
+
240
+ function enqueueSystemSteering(engine, runId, content, metadata = {}) {
241
+ const runMeta = engine.getRunMeta(runId);
242
+ const trimmed = typeof content === 'string' ? content.trim() : '';
243
+ if (!runMeta || runMeta.aborted || !trimmed) return null;
244
+ if (!Array.isArray(runMeta.systemSteeringQueue)) {
245
+ runMeta.systemSteeringQueue = [];
246
+ }
247
+ const signature = JSON.stringify({
248
+ content: trimmed,
249
+ reason: metadata.reason || '',
250
+ });
251
+ if (runMeta.systemSteeringQueue.some((item) => item.signature === signature)) {
252
+ return null;
253
+ }
254
+ const item = {
255
+ id: uuidv4(),
256
+ content: trimmed,
257
+ metadata,
258
+ signature,
259
+ createdAt: isoNow(),
260
+ };
261
+ runMeta.systemSteeringQueue.push(item);
262
+ return item;
263
+ }
264
+
265
+ function applyQueuedSystemSteering(engine, runId, messages) {
266
+ const runMeta = engine.getRunMeta(runId);
267
+ if (!runMeta?.systemSteeringQueue?.length) {
268
+ return { messages, appliedCount: 0 };
269
+ }
270
+
271
+ const queued = runMeta.systemSteeringQueue.splice(0, runMeta.systemSteeringQueue.length);
272
+ for (const entry of queued) {
273
+ messages.push({ role: 'system', content: entry.content });
274
+ }
275
+
276
+ return { messages, appliedCount: queued.length };
277
+ }
278
+
279
+ function applyQueuedSteering(engine, runId, messages, { userId, conversationId }) {
280
+ const runMeta = engine.getRunMeta(runId);
281
+ if (!runMeta?.steeringQueue?.length) {
282
+ return { messages, appliedCount: 0 };
283
+ }
284
+
285
+ const queued = runMeta.steeringQueue.splice(0, runMeta.steeringQueue.length);
286
+ messages.push({
287
+ role: 'system',
288
+ content: [
289
+ 'The user sent follow-up messages while you were already working.',
290
+ 'Treat them as steering or next-up context for the same conversation.',
291
+ 'If a message materially changes the active task, incorporate it now.',
292
+ 'If it is unrelated or better handled after the current task, finish the current work first and then address it.',
293
+ ].join(' '),
294
+ });
295
+
296
+ for (const entry of queued) {
297
+ messages.push({ role: 'user', content: entry.content });
298
+ if (conversationId) {
299
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content) VALUES (?, ?, ?)')
300
+ .run(conversationId, 'user', entry.content);
301
+ }
302
+ }
303
+
304
+ engine.emit(userId, 'run:steer_applied', {
305
+ runId,
306
+ count: queued.length,
307
+ pendingCount: runMeta.steeringQueue.length,
308
+ latestContent: queued[queued.length - 1]?.content || '',
309
+ });
310
+
311
+ return { messages, appliedCount: queued.length };
312
+ }
313
+
314
+ function isRunStopped(engine, runId) {
315
+ return engine.getRunMeta(runId)?.aborted === true;
316
+ }
317
+
318
+ function attachProcessToRun(engine, runId, pid) {
319
+ const runMeta = engine.getRunMeta(runId);
320
+ if (!runMeta || !pid) return;
321
+ runMeta.toolPids.add(pid);
322
+ if (runMeta.aborted) {
323
+ if (engine.runtimeManager && typeof engine.runtimeManager.killCommand === 'function') {
324
+ void engine.runtimeManager.killCommand(runMeta.userId, pid, 'aborted');
325
+ }
326
+ }
327
+ }
328
+
329
+ function detachProcessFromRun(engine, runId, pid) {
330
+ const runMeta = engine.getRunMeta(runId);
331
+ if (!runMeta || !pid) return;
332
+ runMeta.toolPids.delete(pid);
333
+ }
334
+
335
+ module.exports = {
336
+ activateToolsForRun,
337
+ applyQueuedSteering,
338
+ applyQueuedSystemSteering,
339
+ attachProcessToRun,
340
+ buildProgressLedgerSnapshot,
341
+ detachProcessFromRun,
342
+ enqueueSteering,
343
+ enqueueSystemSteering,
344
+ findActiveRunForUser,
345
+ findSteerableRunForUser,
346
+ getActiveTools,
347
+ initializeToolRuntime,
348
+ isRunStopped,
349
+ markRunFinalDelivery,
350
+ markRunVisibleProgress,
351
+ persistProgressLedger,
352
+ persistRunMetadata,
353
+ recordRunEventSafe,
354
+ updateRunGoalContract,
355
+ updateRunProgress,
356
+ };
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+
3
+ const { v4: uuidv4 } = require('uuid');
4
+ const db = require('../../../db/database');
5
+ const { globalHooks } = require('../hooks');
6
+ const { summarizeForLog } = require('../logFormat');
7
+ const { inferToolFailureMessage } = require('../toolEvidence');
8
+
9
+ function getAvailableTools(_engine, app, options = {}) {
10
+ const { getAvailableTools: loadAvailableTools } = require('../tools');
11
+ return loadAvailableTools(app, options);
12
+ }
13
+
14
+ async function executeTool(engine, toolName, args, context) {
15
+ const { executeTool: runTool } = require('../tools');
16
+ return runTool(toolName, args, context, engine);
17
+ }
18
+
19
+ function isReadOnlyToolCall(toolCall) {
20
+ const name = String(toolCall?.function?.name || '');
21
+ const readOnly = new Set([
22
+ 'read_file',
23
+ 'list_directory',
24
+ 'search_files',
25
+ 'code_navigate',
26
+ 'query_structured_data',
27
+ 'memory_recall',
28
+ 'memory_read',
29
+ 'session_search',
30
+ 'web_search',
31
+ 'list_tasks',
32
+ 'list_skills',
33
+ 'list_subagents',
34
+ 'recordings_list',
35
+ 'recordings_get',
36
+ 'recordings_search',
37
+ 'read_health_data',
38
+ ]);
39
+ if (name === 'http_request') {
40
+ try {
41
+ const args = JSON.parse(toolCall.function.arguments || '{}');
42
+ return String(args.method || 'GET').toUpperCase() === 'GET';
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+ return readOnly.has(name);
48
+ }
49
+
50
+ async function executeReadOnlyBatch(engine, toolCalls, context = {}) {
51
+ const {
52
+ userId,
53
+ runId,
54
+ agentId,
55
+ app,
56
+ triggerType,
57
+ triggerSource,
58
+ conversationId,
59
+ startingStepIndex,
60
+ options = {},
61
+ } = context;
62
+ const prepared = [];
63
+ let nextStepIndex = startingStepIndex;
64
+ for (const toolCall of toolCalls) {
65
+ nextStepIndex += 1;
66
+ let toolArgs = {};
67
+ try { toolArgs = JSON.parse(toolCall.function.arguments || '{}'); } catch {}
68
+ const toolName = toolCall.function.name;
69
+ const repetitionGuard = engine.getRunMeta(runId)?.repetitionGuard;
70
+ if (repetitionGuard?.shouldBlock(toolName, toolArgs)) {
71
+ const result = {
72
+ status: 'blocked',
73
+ reason: 'The same read-only call already returned an unchanged result twice.',
74
+ };
75
+ prepared.push({
76
+ toolCall,
77
+ toolName,
78
+ toolArgs,
79
+ stepIndex: nextStepIndex,
80
+ blocked: true,
81
+ result,
82
+ error: result.reason,
83
+ });
84
+ engine.recordRunEvent(userId, runId, 'repetition_blocked', {
85
+ toolName,
86
+ toolArgs,
87
+ parallel: true,
88
+ }, { agentId });
89
+ continue;
90
+ }
91
+ if (globalHooks.has('before_tool_call')) {
92
+ const hookResult = await globalHooks.run('before_tool_call', {
93
+ toolName,
94
+ toolArgs,
95
+ runId,
96
+ userId,
97
+ agentId,
98
+ iteration: context.iteration,
99
+ });
100
+ if (hookResult.block) {
101
+ prepared.push({
102
+ toolCall,
103
+ toolName,
104
+ toolArgs,
105
+ stepIndex: nextStepIndex,
106
+ blocked: true,
107
+ result: { status: 'blocked', reason: hookResult.reason || 'Blocked by policy.', blocked_by: hookResult.blocked_by || 'policy' },
108
+ });
109
+ continue;
110
+ }
111
+ if (hookResult.toolArgs) toolArgs = hookResult.toolArgs;
112
+ }
113
+ const stepId = uuidv4();
114
+ db.prepare(
115
+ `INSERT INTO agent_steps (
116
+ id, run_id, step_index, type, description, status, tool_name, tool_input, started_at
117
+ ) VALUES (?, ?, ?, ?, ?, 'running', ?, ?, datetime('now'))`
118
+ ).run(
119
+ stepId,
120
+ runId,
121
+ nextStepIndex,
122
+ engine.getStepType(toolName),
123
+ `${toolName}: ${JSON.stringify(toolArgs).slice(0, 200)}`,
124
+ toolName,
125
+ JSON.stringify(toolArgs),
126
+ );
127
+ engine.emit(userId, 'run:tool_start', {
128
+ runId,
129
+ stepId,
130
+ stepIndex: nextStepIndex,
131
+ toolName,
132
+ toolArgs,
133
+ type: engine.getStepType(toolName),
134
+ });
135
+ engine.recordRunEvent(userId, runId, 'tool_started', {
136
+ stepIndex: nextStepIndex,
137
+ toolName,
138
+ toolArgs,
139
+ type: engine.getStepType(toolName),
140
+ parallel: true,
141
+ }, { agentId, stepId });
142
+ prepared.push({ toolCall, toolName, toolArgs, stepId, stepIndex: nextStepIndex });
143
+ }
144
+ engine.recordRunEvent(userId, runId, 'parallel_batch_started', {
145
+ toolNames: prepared.map((item) => item.toolName),
146
+ count: prepared.length,
147
+ }, { agentId });
148
+ const results = await Promise.all(prepared.map(async (item) => {
149
+ if (item.blocked) return item;
150
+ const startedAt = Date.now();
151
+ try {
152
+ const result = await executeTool(engine, item.toolName, item.toolArgs, {
153
+ userId,
154
+ runId,
155
+ agentId,
156
+ app,
157
+ triggerType,
158
+ triggerSource,
159
+ conversationId,
160
+ source: options.source || null,
161
+ chatId: options.chatId || null,
162
+ taskId: options.taskId || null,
163
+ widgetId: options.widgetId || null,
164
+ deliveryState: options.deliveryState || null,
165
+ allowMultipleProactiveMessages: options.allowMultipleProactiveMessages === true,
166
+ allowExternalSideEffects: false,
167
+ });
168
+ const error = inferToolFailureMessage(item.toolName, result);
169
+ const status = error ? 'failed' : 'completed';
170
+ db.prepare(
171
+ `UPDATE agent_steps
172
+ SET status = ?, result = ?, error = ?, screenshot_path = ?, completed_at = datetime('now')
173
+ WHERE id = ?`
174
+ ).run(
175
+ status,
176
+ JSON.stringify(result).slice(0, 20000),
177
+ error || null,
178
+ result?.screenshotPath || null,
179
+ item.stepId,
180
+ );
181
+ engine.emit(userId, 'run:tool_end', {
182
+ runId,
183
+ stepId: item.stepId,
184
+ toolName: item.toolName,
185
+ result,
186
+ error: error || undefined,
187
+ status,
188
+ });
189
+ engine.recordRunEvent(userId, runId, error ? 'tool_failed' : 'tool_completed', {
190
+ toolName: item.toolName,
191
+ status,
192
+ durationMs: Date.now() - startedAt,
193
+ resultPreview: summarizeForLog(result),
194
+ parallel: true,
195
+ }, { agentId, stepId: item.stepId });
196
+ return { ...item, result, error };
197
+ } catch (err) {
198
+ db.prepare(
199
+ `UPDATE agent_steps SET status = 'failed', error = ?, completed_at = datetime('now') WHERE id = ?`
200
+ ).run(err.message, item.stepId);
201
+ engine.emit(userId, 'run:tool_end', {
202
+ runId,
203
+ stepId: item.stepId,
204
+ toolName: item.toolName,
205
+ error: err.message,
206
+ status: 'failed',
207
+ });
208
+ engine.recordRunEvent(userId, runId, 'tool_failed', {
209
+ toolName: item.toolName,
210
+ status: 'failed',
211
+ error: err.message,
212
+ durationMs: Date.now() - startedAt,
213
+ parallel: true,
214
+ }, { agentId, stepId: item.stepId });
215
+ return { ...item, result: { error: err.message }, error: err.message };
216
+ }
217
+ }));
218
+ engine.recordRunEvent(userId, runId, 'parallel_batch_completed', {
219
+ toolNames: results.map((item) => item.toolName),
220
+ failedCount: results.filter((item) => item.error).length,
221
+ }, { agentId });
222
+ return { results, endingStepIndex: nextStepIndex };
223
+ }
224
+
225
+ module.exports = {
226
+ executeReadOnlyBatch,
227
+ executeTool,
228
+ getAvailableTools,
229
+ isReadOnlyToolCall,
230
+ };
@@ -9,6 +9,7 @@ const {
9
9
  normalizeOutgoingMessageForPlatform,
10
10
  } = require('../messaging/formatting_guides');
11
11
  const { INTERIM_KINDS, normalizeInterimKind } = require('./interim');
12
+ const { normalizeWhatsAppId } = require('../../utils/whatsapp');
12
13
  const {
13
14
  executeIntegratedTool,
14
15
  getIntegratedToolDefinitions,
@@ -320,6 +321,31 @@ function normalizeMessagingTarget(target = {}) {
320
321
  return { platform, to };
321
322
  }
322
323
 
324
+ function canonicalMessagingAddress(platform, value) {
325
+ const normalizedPlatform = String(platform || '').trim().toLowerCase();
326
+ const raw = String(value || '').trim();
327
+ if (!normalizedPlatform || !raw) return '';
328
+ if (normalizedPlatform !== 'whatsapp') return raw;
329
+
330
+ const lower = raw.toLowerCase();
331
+ const normalizedId = normalizeWhatsAppId(lower);
332
+ if (!normalizedId) return '';
333
+ if (lower.includes('@g.us')) return `group:${normalizedId}`;
334
+ if (lower.includes('@lid')) return `lid:${normalizedId}`;
335
+ return `direct:${normalizedId}`;
336
+ }
337
+
338
+ function isOriginMessagingDelivery({ triggerSource, source, chatId, platform, to }) {
339
+ if (triggerSource !== 'messaging') return true;
340
+ const originPlatform = String(source || '').trim().toLowerCase();
341
+ const targetPlatform = String(platform || '').trim().toLowerCase();
342
+ if (!originPlatform || !targetPlatform || originPlatform !== targetPlatform) return false;
343
+
344
+ const originAddress = canonicalMessagingAddress(originPlatform, chatId);
345
+ const targetAddress = canonicalMessagingAddress(targetPlatform, to);
346
+ return Boolean(originAddress && targetAddress && originAddress === targetAddress);
347
+ }
348
+
323
349
  function buildAndroidUiMatchProperties(extra = {}) {
324
350
  return {
325
351
  x: { type: 'number', description: 'Absolute X coordinate' },
@@ -1623,6 +1649,7 @@ async function executeTool(toolName, args, context, engine) {
1623
1649
  case 'browser_extract': {
1624
1650
  const { provider, backend } = await bc();
1625
1651
  if (!provider) return { error: 'Browser controller not available' };
1652
+ if (!args.selector) return { error: 'browser_extract requires a "selector" argument' };
1626
1653
  return { ...await provider.extract(args.selector, args.attribute, args.all), backend };
1627
1654
  }
1628
1655
 
@@ -1635,7 +1662,9 @@ async function executeTool(toolName, args, context, engine) {
1635
1662
  case 'browser_evaluate': {
1636
1663
  const { provider, backend } = await bc();
1637
1664
  if (!provider) return { error: 'Browser controller not available' };
1638
- return { ...await provider.evaluate(args.script), backend };
1665
+ const script = args.script ?? args.javascript;
1666
+ if (!script) return { error: 'browser_evaluate requires a "script" argument' };
1667
+ return { ...await provider.evaluate(script), backend };
1639
1668
  }
1640
1669
 
1641
1670
  case 'android_start_emulator': {
@@ -2244,7 +2273,18 @@ async function executeTool(toolName, args, context, engine) {
2244
2273
  persistConversation: triggerSource === 'schedule' || triggerSource === 'tasks'
2245
2274
  });
2246
2275
  // Track that the agent explicitly sent a message during this run
2247
- if (!suppressReply && sendResult?.suppressed !== true) {
2276
+ if (
2277
+ !suppressReply
2278
+ && sendResult?.success === true
2279
+ && sendResult?.suppressed !== true
2280
+ && isOriginMessagingDelivery({
2281
+ triggerSource,
2282
+ source: context.source,
2283
+ chatId: context.chatId,
2284
+ platform: args.platform,
2285
+ to: args.to,
2286
+ })
2287
+ ) {
2248
2288
  markProactiveMessageSent({ runState, deliveryState, content: normalizedMessage });
2249
2289
  if (runState && triggerSource === 'messaging') {
2250
2290
  runState.explicitMessageSent = true;
@@ -515,6 +515,13 @@ class MessagingManager extends EventEmitter {
515
515
  }
516
516
 
517
517
  const result = await platform.sendMessage(to, normalizedContent, sendOptions);
518
+ if (result?.success === false) {
519
+ const reason = result.error || result.reason || 'platform rejected the message';
520
+ const error = new Error(`Platform ${platformName} delivery failed: ${reason}`);
521
+ error.code = 'MESSAGING_DELIVERY_FAILED';
522
+ error.deliveryResult = result;
523
+ throw error;
524
+ }
518
525
 
519
526
  db.prepare('INSERT INTO messages (user_id, agent_id, run_id, role, content, platform, platform_chat_id, media_path, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
520
527
  .run(userId, agentId, runId, 'assistant', normalizedContent, platformName, to, mediaPath, metadata ? JSON.stringify(metadata) : null);