neoagent 2.5.2-beta.1 → 2.5.2-beta.3

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.
@@ -116,6 +116,7 @@ const MESSAGING_PROGRESS_FIRST_UPDATE_MS = 60 * 1000;
116
116
  const MESSAGING_PROGRESS_REPEAT_MS = 90 * 1000;
117
117
  const MESSAGING_PROGRESS_STALL_MS = 240 * 1000;
118
118
  const MESSAGING_PROGRESS_TICK_MS = 15 * 1000;
119
+ const GOAL_CONTRACT_SUCCESS_CRITERIA_LIMIT = 12;
119
120
 
120
121
  function isoNow() {
121
122
  return new Date().toISOString();
@@ -186,6 +187,302 @@ function hasVisibleInterimActivity(runMeta) {
186
187
  );
187
188
  }
188
189
 
190
+ function normalizeGoalCriteria(value) {
191
+ if (!Array.isArray(value)) return [];
192
+ const seen = new Set();
193
+ const items = [];
194
+ for (const entry of value) {
195
+ const text = String(entry || '').trim();
196
+ if (!text) continue;
197
+ const signature = text.toLowerCase();
198
+ if (seen.has(signature)) continue;
199
+ seen.add(signature);
200
+ items.push(text);
201
+ if (items.length >= GOAL_CONTRACT_SUCCESS_CRITERIA_LIMIT) break;
202
+ }
203
+ return items;
204
+ }
205
+
206
+ function normalizeGoalContract(raw = null) {
207
+ if (!raw || typeof raw !== 'object') return null;
208
+ const goal = String(raw.goal || '').trim();
209
+ const successCriteria = normalizeGoalCriteria(
210
+ raw.successCriteria || raw.success_criteria || [],
211
+ );
212
+ const rawCompletionConfidence = String(
213
+ raw.completionConfidenceRequired || raw.completion_confidence_required || '',
214
+ ).trim();
215
+ const completionConfidenceRequired = rawCompletionConfidence
216
+ ? normalizeCompletionConfidence(rawCompletionConfidence)
217
+ : '';
218
+ const progressUpdatePolicy = ['none', 'optional', 'required'].includes(String(
219
+ raw.progressUpdatePolicy || raw.progress_update_policy || '',
220
+ ).trim().toLowerCase())
221
+ ? String(raw.progressUpdatePolicy || raw.progress_update_policy || '').trim().toLowerCase()
222
+ : '';
223
+ const autonomyLevel = ['minimal', 'normal', 'high'].includes(String(
224
+ raw.autonomyLevel || raw.autonomy_level || '',
225
+ ).trim().toLowerCase())
226
+ ? String(raw.autonomyLevel || raw.autonomy_level || '').trim().toLowerCase()
227
+ : '';
228
+ const complexity = ['simple', 'standard', 'complex'].includes(String(
229
+ raw.complexity || '',
230
+ ).trim().toLowerCase())
231
+ ? String(raw.complexity || '').trim().toLowerCase()
232
+ : '';
233
+
234
+ if (
235
+ !goal
236
+ && successCriteria.length === 0
237
+ && !completionConfidenceRequired
238
+ && !progressUpdatePolicy
239
+ && !autonomyLevel
240
+ && !complexity
241
+ ) {
242
+ return null;
243
+ }
244
+
245
+ return {
246
+ goal,
247
+ successCriteria,
248
+ completionConfidenceRequired,
249
+ progressUpdatePolicy: progressUpdatePolicy || '',
250
+ autonomyLevel: autonomyLevel || '',
251
+ complexity: complexity || '',
252
+ };
253
+ }
254
+
255
+ function mergeGoalContracts(existing = null, patch = null) {
256
+ const current = normalizeGoalContract(existing) || null;
257
+ const nextPatch = normalizeGoalContract(patch) || null;
258
+ if (!current && !nextPatch) return null;
259
+
260
+ const goal = String(nextPatch?.goal || current?.goal || '').trim();
261
+ const successCriteria = normalizeGoalCriteria([
262
+ ...(current?.successCriteria || []),
263
+ ...(nextPatch?.successCriteria || []),
264
+ ]);
265
+ const completionConfidenceRequired = nextPatch?.completionConfidenceRequired
266
+ || current?.completionConfidenceRequired
267
+ || 'medium';
268
+ const progressUpdatePolicy = nextPatch?.progressUpdatePolicy
269
+ || current?.progressUpdatePolicy
270
+ || '';
271
+ const autonomyLevel = nextPatch?.autonomyLevel
272
+ || current?.autonomyLevel
273
+ || '';
274
+ const complexity = nextPatch?.complexity
275
+ || current?.complexity
276
+ || '';
277
+
278
+ return normalizeGoalContract({
279
+ goal,
280
+ successCriteria,
281
+ completionConfidenceRequired,
282
+ progressUpdatePolicy,
283
+ autonomyLevel,
284
+ complexity,
285
+ });
286
+ }
287
+
288
+ function goalContractFromAnalysis(analysis = null) {
289
+ if (!analysis || typeof analysis !== 'object') return null;
290
+ return normalizeGoalContract({
291
+ goal: analysis.goal,
292
+ successCriteria: analysis.success_criteria,
293
+ completionConfidenceRequired: analysis.completion_confidence_required,
294
+ progressUpdatePolicy: analysis.progress_update_policy,
295
+ autonomyLevel: analysis.autonomy_level,
296
+ complexity: analysis.complexity,
297
+ });
298
+ }
299
+
300
+ function goalContractFromPlan(plan = null) {
301
+ if (!plan || typeof plan !== 'object') return null;
302
+ return normalizeGoalContract({
303
+ successCriteria: plan.success_criteria,
304
+ });
305
+ }
306
+
307
+ function buildResolvedGoalContract(runMeta, analysis = null, plan = null) {
308
+ let contract = mergeGoalContracts(runMeta?.goalContract || null, goalContractFromAnalysis(analysis));
309
+ contract = mergeGoalContracts(contract, goalContractFromPlan(plan));
310
+ return contract;
311
+ }
312
+
313
+ function buildGoalContractPrompt(contract, label = 'Persistent run goal') {
314
+ const normalized = normalizeGoalContract(contract);
315
+ if (!normalized) return '';
316
+ const lines = [];
317
+ if (normalized.goal) {
318
+ lines.push(`${label}: ${normalized.goal}`);
319
+ }
320
+ if (normalized.successCriteria.length > 0) {
321
+ lines.push(`Persistent success criteria:\n- ${normalized.successCriteria.join('\n- ')}`);
322
+ }
323
+ const contractLine = [
324
+ normalized.complexity ? `complexity=${normalized.complexity}` : '',
325
+ normalized.autonomyLevel ? `autonomy_level=${normalized.autonomyLevel}` : '',
326
+ normalized.progressUpdatePolicy ? `progress_update_policy=${normalized.progressUpdatePolicy}` : '',
327
+ normalized.completionConfidenceRequired ? `completion_confidence_required=${normalized.completionConfidenceRequired}` : '',
328
+ ].filter(Boolean).join('; ');
329
+ if (contractLine) {
330
+ lines.push(`Persistent autonomy contract: ${contractLine}`);
331
+ }
332
+ return lines.join('\n');
333
+ }
334
+
335
+ function resolveRunGoalContext(runMeta, analysis = null, plan = null) {
336
+ const goalContract = buildResolvedGoalContract(runMeta, analysis, plan);
337
+ const successCriteria = goalContract?.successCriteria?.length
338
+ ? goalContract.successCriteria.slice(0, 6)
339
+ : (Array.isArray(plan?.success_criteria)
340
+ ? plan.success_criteria
341
+ .map((item) => String(item || '').trim())
342
+ .filter(Boolean)
343
+ .slice(0, 6)
344
+ : []);
345
+ const effectiveGoal = goalContract?.goal || analysis?.goal || '';
346
+ const effectiveComplexity = goalContract?.complexity || analysis?.complexity || 'standard';
347
+ const effectiveAutonomyLevel = goalContract?.autonomyLevel || analysis?.autonomy_level || 'normal';
348
+ const effectiveProgressPolicy = goalContract?.progressUpdatePolicy || analysis?.progress_update_policy || 'optional';
349
+ const effectiveCompletionConfidence = goalContract?.completionConfidenceRequired
350
+ || analysis?.completion_confidence_required
351
+ || 'medium';
352
+ const persistedGoalPrompt = buildGoalContractPrompt(goalContract);
353
+ return {
354
+ goalContract,
355
+ successCriteria,
356
+ effectiveGoal,
357
+ effectiveComplexity,
358
+ effectiveAutonomyLevel,
359
+ effectiveProgressPolicy,
360
+ effectiveCompletionConfidence,
361
+ persistedGoalPrompt,
362
+ };
363
+ }
364
+
365
+ function buildCompletionDecisionPrompt({
366
+ mode,
367
+ triggerSource,
368
+ messagingSent = false,
369
+ goalContext,
370
+ parallelWork = false,
371
+ tools,
372
+ toolExecutions,
373
+ lastReply,
374
+ iteration,
375
+ maxIterations,
376
+ progressSummary = '',
377
+ platform = null,
378
+ }) {
379
+ const draftReply = mode === 'messaging'
380
+ ? (normalizeOutgoingMessage(lastReply || '', platform, { collapseWhitespace: false })
381
+ ? String(lastReply || '').trim()
382
+ : '')
383
+ : normalizeOutgoingMessage(lastReply) || '';
384
+ const lines = [
385
+ 'Return JSON only.',
386
+ ];
387
+
388
+ if (mode === 'messaging') {
389
+ lines.push(
390
+ 'A messaging run is about to stop after sending user-visible progress, but no final delivery has happened yet.',
391
+ 'Decide whether the run should keep working, finish with the completed result now, or stop with one blocker reply now.',
392
+ 'Schema: {"status":"continue|complete|blocked","reason":"short concrete reason","final_reply":"string"}',
393
+ 'Rules:',
394
+ '- Use "continue" whenever any safe next step remains in this same run.',
395
+ '- Use "complete" only when the requested outcome is actually achieved and final_reply is the finished user-facing answer to send now.',
396
+ '- Use "blocked" only when a specific external dependency, missing user input, or permission outside this run is required and final_reply is the concise blocker reply to send now.',
397
+ '- A progress note, next-step note, apology, plan, or "I will investigate" draft is "continue", not "complete" and not "blocked".',
398
+ '- If user-visible progress was already sent and no final delivery exists yet, do not stop silently and do not stop on a status-only draft.',
399
+ '- final_reply must be empty when status is "continue".',
400
+ );
401
+ } else {
402
+ lines.push(
403
+ 'Decide whether this run should continue autonomously or stop now.',
404
+ 'Schema: {"status":"continue|complete|blocked","reason":"short concrete reason"}',
405
+ 'Rules:',
406
+ '- Use "continue" whenever any safe next step remains in this same run.',
407
+ '- Use "complete" only when the requested outcome is actually achieved or a truthful final user reply is already ready now.',
408
+ '- Use "blocked" only when a specific external dependency outside this run is required.',
409
+ '- If the latest draft asks the user for a missing required value, confirmation, or choice needed to proceed, use "blocked" so the run waits instead of repeating the same ask.',
410
+ '- A progress update is not complete.',
411
+ '- A single failed tool attempt is not blocked if another safe retry, verification step, or alternative path remains.',
412
+ '- A tool-specific API error, timeout, rate limit, or missing result inside this run is usually "continue", not "blocked", if any other available tool could still make progress.',
413
+ `- If completion_confidence_required is ${goalContext.effectiveCompletionConfidence} and the latest draft depends on unverified assumptions, use "continue" so the run can gather evidence, inspect state, or narrow the reply.`,
414
+ triggerSource === 'messaging' && messagingSent
415
+ ? '- A reply was already delivered to the user via send_message. Use "complete" unless there is concrete remaining work (e.g., a tool call you still need to make) before the task is truly done. Do not send follow-up elaborations or re-introductions.'
416
+ : triggerSource === 'messaging'
417
+ ? '- For messaging, do not stop on a partial status message. Continue unless the task is actually complete or externally blocked. If you already asked for missing user input, choose "blocked" and wait.'
418
+ : '- Do not stop just because you wrote a status update. Continue unless the task is actually complete or externally blocked.',
419
+ );
420
+ }
421
+
422
+ lines.push(
423
+ goalContext.effectiveGoal ? `Goal: ${goalContext.effectiveGoal}` : '',
424
+ goalContext.persistedGoalPrompt,
425
+ `Autonomy contract: complexity=${goalContext.effectiveComplexity}; autonomy_level=${goalContext.effectiveAutonomyLevel}; progress_update_policy=${goalContext.effectiveProgressPolicy}; parallel_work=${parallelWork === true}; completion_confidence_required=${goalContext.effectiveCompletionConfidence}.`,
426
+ goalContext.successCriteria.length > 0
427
+ ? `Success criteria:\n${goalContext.successCriteria.map((item, index) => `${index + 1}. ${item}`).join('\n')}`
428
+ : '',
429
+ `Current iteration: ${iteration} of ${maxIterations}.`,
430
+ `Available tools in this run: ${summarizeAvailableTools(tools) || 'none'}`,
431
+ mode === 'messaging' && progressSummary ? `Progress ledger: ${progressSummary}` : '',
432
+ `Recent tool evidence:\n${summarizeToolExecutions(toolExecutions, 8) || 'none'}`,
433
+ `Latest draft reply:\n${draftReply || '(empty)'}`,
434
+ mode === 'messaging' ? buildPlatformFormattingGuide(platform) : '',
435
+ );
436
+ return lines.filter(Boolean).join('\n');
437
+ }
438
+
439
+ function normalizeCompletionDecision(raw, {
440
+ mode,
441
+ fallbackStatus = 'continue',
442
+ platform = null,
443
+ draftReply = '',
444
+ }) {
445
+ const allowed = new Set(['continue', 'complete', 'blocked']);
446
+ if (mode === 'messaging') {
447
+ let status = allowed.has(String(raw.status || '').trim().toLowerCase())
448
+ ? String(raw.status || '').trim().toLowerCase()
449
+ : 'continue';
450
+ let finalReply = normalizeOutgoingMessage(raw.final_reply || '', platform, {
451
+ collapseWhitespace: false,
452
+ })
453
+ ? String(raw.final_reply || '').trim()
454
+ : '';
455
+ if (status === 'continue') {
456
+ finalReply = '';
457
+ } else if (!finalReply && draftReply) {
458
+ finalReply = draftReply;
459
+ } else if (!finalReply) {
460
+ status = 'continue';
461
+ }
462
+ return {
463
+ status,
464
+ reason: String(raw.reason || '').trim().slice(0, 400),
465
+ final_reply: finalReply,
466
+ };
467
+ }
468
+
469
+ const requestedStatus = String(raw.status || '').trim().toLowerCase();
470
+ return {
471
+ status: allowed.has(requestedStatus) ? requestedStatus : fallbackStatus,
472
+ reason: String(raw.reason || '').trim().slice(0, 400),
473
+ };
474
+ }
475
+
476
+ function shouldRequireMessagingFinalityCheck(runMeta) {
477
+ return Boolean(
478
+ runMeta
479
+ && runMeta.triggerSource === 'messaging'
480
+ && runMeta.finalDeliverySent !== true
481
+ && !runMeta.terminalInterim
482
+ && hasVisibleInterimActivity(runMeta)
483
+ );
484
+ }
485
+
189
486
  function planningDepthForForceMode(forceMode) {
190
487
  return forceMode === 'plan_execute' ? 'deep' : 'light';
191
488
  }
@@ -629,6 +926,33 @@ class AgentEngine {
629
926
  .run(JSON.stringify(next), runId);
630
927
  }
631
928
 
929
+ replaceLatestConversationAssistantMessage(conversationId, content) {
930
+ if (!conversationId) return false;
931
+ const messageId = db.prepare(
932
+ `SELECT id
933
+ FROM conversation_messages
934
+ WHERE conversation_id = ? AND role = 'assistant'
935
+ ORDER BY id DESC
936
+ LIMIT 1`
937
+ ).get(conversationId)?.id;
938
+ if (!messageId) return false;
939
+ db.prepare('UPDATE conversation_messages SET content = ? WHERE id = ?')
940
+ .run(content, messageId);
941
+ return true;
942
+ }
943
+
944
+ updateRunGoalContract(runId, patch = {}, options = {}) {
945
+ const runMeta = this.getRunMeta(runId);
946
+ if (!runMeta) return null;
947
+ runMeta.goalContract = mergeGoalContracts(runMeta.goalContract, patch);
948
+ if (options.persist !== false) {
949
+ this.persistRunMetadata(runId, {
950
+ goalContract: runMeta.goalContract,
951
+ });
952
+ }
953
+ return runMeta.goalContract;
954
+ }
955
+
632
956
  buildProgressLedgerSnapshot(runMeta) {
633
957
  if (!runMeta?.progressLedger) return null;
634
958
  return {
@@ -1152,53 +1476,31 @@ class AgentEngine {
1152
1476
  options,
1153
1477
  fallbackStatus,
1154
1478
  }) {
1155
- const successCriteria = Array.isArray(plan?.success_criteria)
1156
- ? plan.success_criteria
1157
- .map((item) => String(item || '').trim())
1158
- .filter(Boolean)
1159
- .slice(0, 6)
1160
- : [];
1479
+ const runMeta = options?.runId ? this.getRunMeta(options.runId) : null;
1480
+ const goalContext = resolveRunGoalContext(runMeta, analysis, plan);
1161
1481
 
1162
1482
  const response = await this.requestStructuredJson({
1163
1483
  provider,
1164
1484
  providerName,
1165
1485
  model,
1166
1486
  messages,
1167
- prompt: [
1168
- 'Return JSON only.',
1169
- 'Decide whether this run should continue autonomously or stop now.',
1170
- 'Schema: {"status":"continue|complete|blocked","reason":"short concrete reason"}',
1171
- 'Rules:',
1172
- '- Use "continue" whenever any safe next step remains in this same run.',
1173
- '- Use "complete" only when the requested outcome is actually achieved or a truthful final user reply is already ready now.',
1174
- '- Use "blocked" only when a specific external dependency outside this run is required.',
1175
- '- If the latest draft asks the user for a missing required value, confirmation, or choice needed to proceed, use "blocked" so the run waits instead of repeating the same ask.',
1176
- '- A progress update is not complete.',
1177
- '- A single failed tool attempt is not blocked if another safe retry, verification step, or alternative path remains.',
1178
- '- A tool-specific API error, timeout, rate limit, or missing result inside this run is usually "continue", not "blocked", if any other available tool could still make progress.',
1179
- '- If completion_confidence_required is high and the latest draft depends on unverified assumptions, use "continue" so the run can gather evidence, inspect state, or narrow the reply.',
1180
- triggerSource === 'messaging' && messagingSent
1181
- ? '- A reply was already delivered to the user via send_message. Use "complete" unless there is concrete remaining work (e.g., a tool call you still need to make) before the task is truly done. Do not send follow-up elaborations or re-introductions.'
1182
- : triggerSource === 'messaging'
1183
- ? '- For messaging, do not stop on a partial status message. Continue unless the task is actually complete or externally blocked. If you already asked for missing user input, choose "blocked" and wait.'
1184
- : '- Do not stop just because you wrote a status update. Continue unless the task is actually complete or externally blocked.',
1185
- analysis?.goal ? `Goal: ${analysis.goal}` : '',
1186
- `Autonomy contract: complexity=${analysis?.complexity || 'standard'}; autonomy_level=${analysis?.autonomy_level || 'normal'}; progress_update_policy=${analysis?.progress_update_policy || 'optional'}; parallel_work=${analysis?.parallel_work === true}; completion_confidence_required=${analysis?.completion_confidence_required || 'medium'}.`,
1187
- successCriteria.length > 0 ? `Success criteria:\n${successCriteria.map((item, index) => `${index + 1}. ${item}`).join('\n')}` : '',
1188
- `Current iteration: ${iteration} of ${maxIterations}.`,
1189
- `Available tools in this run: ${summarizeAvailableTools(tools) || 'none'}`,
1190
- `Recent tool evidence:\n${summarizeToolExecutions(toolExecutions, 8) || 'none'}`,
1191
- `Latest draft reply:\n${normalizeOutgoingMessage(lastReply) || '(empty)'}`,
1192
- ].filter(Boolean).join('\n'),
1487
+ prompt: buildCompletionDecisionPrompt({
1488
+ mode: 'loop',
1489
+ triggerSource,
1490
+ messagingSent,
1491
+ goalContext,
1492
+ parallelWork: analysis?.parallel_work === true,
1493
+ tools,
1494
+ toolExecutions,
1495
+ lastReply,
1496
+ iteration,
1497
+ maxIterations,
1498
+ }),
1193
1499
  maxTokens: 320,
1194
- normalize: (raw) => {
1195
- const allowed = new Set(['continue', 'complete', 'blocked']);
1196
- const requestedStatus = String(raw.status || '').trim().toLowerCase();
1197
- return {
1198
- status: allowed.has(requestedStatus) ? requestedStatus : fallbackStatus,
1199
- reason: String(raw.reason || '').trim().slice(0, 400),
1200
- };
1201
- },
1500
+ normalize: (raw) => normalizeCompletionDecision(raw, {
1501
+ mode: 'loop',
1502
+ fallbackStatus,
1503
+ }),
1202
1504
  fallback: { status: fallbackStatus },
1203
1505
  reasoningEffort: this.getReasoningEffort(providerName, options),
1204
1506
  telemetry: options,
@@ -1825,23 +2127,192 @@ class AgentEngine {
1825
2127
  return { messages, appliedCount: queued.length };
1826
2128
  }
1827
2129
 
2130
+ async decideMessagingCompletionState({
2131
+ provider,
2132
+ providerName,
2133
+ model,
2134
+ messages,
2135
+ analysis,
2136
+ plan,
2137
+ tools,
2138
+ toolExecutions,
2139
+ lastReply,
2140
+ iteration,
2141
+ maxIterations,
2142
+ runId,
2143
+ options,
2144
+ }) {
2145
+ const runMeta = this.getRunMeta(runId);
2146
+ const goalContext = resolveRunGoalContext(runMeta, analysis, plan);
2147
+ const platform = options?.source || null;
2148
+ const normalizedDraft = normalizeOutgoingMessage(lastReply || '', platform, {
2149
+ collapseWhitespace: false,
2150
+ });
2151
+ const draftReply = normalizedDraft ? String(lastReply || '').trim() : '';
2152
+ const ledger = runMeta?.progressLedger || null;
2153
+ const progressSummary = [
2154
+ `progress_state=${ledger?.progressState || 'active'}`,
2155
+ `current_phase=${ledger?.currentPhase || 'idle'}`,
2156
+ `current_tool=${ledger?.currentTool || 'none'}`,
2157
+ `heartbeat_count=${Number(ledger?.heartbeatCount || 0)}`,
2158
+ `last_visible_update=${ledger?.lastUserVisibleUpdateAt || 'none'}`,
2159
+ `last_verified_progress=${ledger?.lastVerifiedProgressAt || 'none'}`,
2160
+ `last_final_delivery=${ledger?.lastFinalDeliveryAt || 'none'}`,
2161
+ ].join('; ');
2162
+
2163
+ const response = await this.requestStructuredJson({
2164
+ provider,
2165
+ providerName,
2166
+ model,
2167
+ messages,
2168
+ prompt: buildCompletionDecisionPrompt({
2169
+ mode: 'messaging',
2170
+ goalContext,
2171
+ parallelWork: analysis?.parallel_work === true,
2172
+ tools,
2173
+ toolExecutions,
2174
+ lastReply: draftReply,
2175
+ iteration,
2176
+ maxIterations,
2177
+ progressSummary,
2178
+ platform,
2179
+ }),
2180
+ maxTokens: 480,
2181
+ normalize: (raw) => normalizeCompletionDecision(raw, {
2182
+ mode: 'messaging',
2183
+ platform,
2184
+ draftReply,
2185
+ }),
2186
+ fallback: {
2187
+ status: 'continue',
2188
+ reason: '',
2189
+ final_reply: '',
2190
+ },
2191
+ reasoningEffort: this.getReasoningEffort(providerName, options),
2192
+ telemetry: options,
2193
+ phase: 'messaging_completion',
2194
+ });
2195
+
2196
+ return {
2197
+ decision: response.value,
2198
+ usage: response.usage,
2199
+ };
2200
+ }
2201
+
2202
+ async resolveMessagingCompletionDecision({
2203
+ provider,
2204
+ providerName,
2205
+ model,
2206
+ messages,
2207
+ analysis,
2208
+ plan,
2209
+ tools,
2210
+ toolExecutions,
2211
+ lastReply,
2212
+ iteration,
2213
+ maxIterations,
2214
+ runId,
2215
+ conversationId,
2216
+ options,
2217
+ }) {
2218
+ const runMeta = this.getRunMeta(runId);
2219
+ if (!shouldRequireMessagingFinalityCheck(runMeta)) {
2220
+ return {
2221
+ action: 'none',
2222
+ content: lastReply,
2223
+ reason: '',
2224
+ usage: 0,
2225
+ };
2226
+ }
2227
+
2228
+ let completionDecision;
2229
+ try {
2230
+ completionDecision = await this.decideMessagingCompletionState({
2231
+ provider,
2232
+ providerName,
2233
+ model,
2234
+ messages,
2235
+ analysis,
2236
+ plan,
2237
+ tools,
2238
+ toolExecutions,
2239
+ lastReply,
2240
+ iteration,
2241
+ maxIterations,
2242
+ runId,
2243
+ options,
2244
+ });
2245
+ } catch (error) {
2246
+ if (iteration >= maxIterations) {
2247
+ const wrapped = new Error(
2248
+ `Messaging completion check failed after visible progress: ${error?.message || error}`,
2249
+ );
2250
+ wrapped.disableAutonomousRetry = error?.disableAutonomousRetry === true;
2251
+ throw wrapped;
2252
+ }
2253
+ return {
2254
+ action: 'continue',
2255
+ content: '',
2256
+ reason: 'The run still needs an explicit final result or blocker decision.',
2257
+ usage: 0,
2258
+ };
2259
+ }
2260
+
2261
+ const decision = completionDecision.decision || { status: 'continue', reason: '' };
2262
+ if (decision.status === 'continue') {
2263
+ if (iteration >= maxIterations) {
2264
+ throw new Error(
2265
+ 'Messaging run reached the iteration limit before producing a final answer or blocker after visible progress.',
2266
+ );
2267
+ }
2268
+ return {
2269
+ action: 'continue',
2270
+ content: '',
2271
+ reason: decision.reason || 'The current draft is still only progress.',
2272
+ usage: completionDecision.usage || 0,
2273
+ };
2274
+ }
2275
+
2276
+ const finalContent = String(decision.final_reply || lastReply || '').trim();
2277
+ if (finalContent && messages[messages.length - 1]?.role === 'assistant') {
2278
+ messages[messages.length - 1] = {
2279
+ ...messages[messages.length - 1],
2280
+ content: finalContent,
2281
+ };
2282
+ this.replaceLatestConversationAssistantMessage(conversationId, finalContent);
2283
+ }
2284
+
2285
+ return {
2286
+ action: decision.status === 'blocked' ? 'blocked' : 'complete',
2287
+ content: finalContent,
2288
+ reason: decision.reason || '',
2289
+ usage: completionDecision.usage || 0,
2290
+ };
2291
+ }
2292
+
1828
2293
  buildMessagingHeartbeatText(runMeta, options = {}) {
1829
2294
  const stalled = options.stalled === true;
1830
- const fallbackStartedAtMs = Number.isFinite(runMeta?.startedAt) ? runMeta.startedAt : Date.now();
1831
- const startedAtMs = timestampMs(
2295
+ const now = Date.now();
2296
+ const runStartedAtMs = Number.isFinite(runMeta?.startedAt) ? runMeta.startedAt : now;
2297
+ const stepStartedAtMs = timestampMs(
1832
2298
  runMeta?.progressLedger?.currentStepStartedAt,
1833
- fallbackStartedAtMs,
2299
+ 0,
1834
2300
  );
1835
- const elapsed = formatElapsedDuration(Date.now() - startedAtMs);
2301
+ const runElapsed = formatElapsedDuration(now - runStartedAtMs);
2302
+ const stepElapsed = formatElapsedDuration(now - (stepStartedAtMs || runStartedAtMs));
2303
+ const unverifiedElapsed = formatElapsedDuration(now - timestampMs(
2304
+ runMeta?.progressLedger?.lastVerifiedProgressAt,
2305
+ runStartedAtMs,
2306
+ ));
1836
2307
  const currentTool = String(runMeta?.progressLedger?.currentTool || '').trim();
1837
2308
  if (currentTool) {
1838
2309
  return stalled
1839
- ? `Still working on ${currentTool}. This run has not made verified progress for ${elapsed}.`
1840
- : `Still working on ${currentTool}. ${elapsed} elapsed so far.`;
2310
+ ? `Still working on ${currentTool}. Run active ${runElapsed}; no verified progress for ${unverifiedElapsed}.`
2311
+ : `Still working on ${currentTool}. Run active ${runElapsed}; current step ${stepElapsed} so far.`;
1841
2312
  }
1842
2313
  return stalled
1843
- ? `Still working on this. This run has not made verified progress for ${elapsed}.`
1844
- : `Still working on this. ${elapsed} elapsed so far.`;
2314
+ ? `Still working on this. Run active ${runElapsed}; no verified progress for ${unverifiedElapsed}.`
2315
+ : `Still working on this. Run active ${runElapsed}.`;
1845
2316
  }
1846
2317
 
1847
2318
  async sendRuntimeMessagingHeartbeat(runId, options = {}) {
@@ -2317,6 +2788,7 @@ class AgentEngine {
2317
2788
  const carriedExplicitMessageSent = retryMessagingState.explicitMessageSent === true;
2318
2789
  const carriedInterimHistory = cloneInterimHistory(retryMessagingState.interimHistory);
2319
2790
  const carriedLastInterimMessage = carriedInterimHistory[carriedInterimHistory.length - 1]?.content || '';
2791
+ const carriedGoalContract = normalizeGoalContract(retryMessagingState.goalContract);
2320
2792
  const startedAtIso = isoNow();
2321
2793
  const progressLedger = buildInitialProgressLedger({
2322
2794
  startedAt: startedAtIso,
@@ -2358,10 +2830,12 @@ class AgentEngine {
2358
2830
  chatId: options.chatId || null,
2359
2831
  }
2360
2832
  : null,
2833
+ goalContract: carriedGoalContract,
2361
2834
  progressLedger,
2362
2835
  });
2363
2836
  this.persistRunMetadata(runId, {
2364
2837
  progressLedger,
2838
+ goalContract: carriedGoalContract,
2365
2839
  });
2366
2840
  this.startMessagingProgressSupervisor(runId);
2367
2841
  this.emit(userId, 'run:start', { runId, agentId, title: runTitle, model, triggerType, triggerSource });
@@ -2459,6 +2933,12 @@ class AgentEngine {
2459
2933
  if (threadStateMessage) {
2460
2934
  messages.push({ role: 'system', content: threadStateMessage });
2461
2935
  }
2936
+ if (carriedGoalContract) {
2937
+ messages.push({
2938
+ role: 'system',
2939
+ content: buildGoalContractPrompt(carriedGoalContract, 'Persisted run goal'),
2940
+ });
2941
+ }
2462
2942
  this.recordRunEvent(userId, runId, 'memory_injected', {
2463
2943
  hasRecallContext: Boolean(recallMsg),
2464
2944
  hasThreadState: Boolean(threadStateMessage),
@@ -2537,6 +3017,7 @@ class AgentEngine {
2537
3017
  taskAnalysis: analysis,
2538
3018
  capabilityHealth,
2539
3019
  });
3020
+ this.updateRunGoalContract(runId, goalContractFromAnalysis(analysis));
2540
3021
  this.emit(userId, 'run:analysis', {
2541
3022
  runId,
2542
3023
  ...analysis,
@@ -2655,6 +3136,9 @@ class AgentEngine {
2655
3136
  plan: deliverablePlan,
2656
3137
  },
2657
3138
  });
3139
+ this.updateRunGoalContract(runId, {
3140
+ goal: deliverableWorkflow.selection.goal,
3141
+ });
2658
3142
  this.recordRunEvent(userId, runId, 'deliverable_workflow_selected', {
2659
3143
  type: deliverableWorkflow.selection.type,
2660
3144
  confidence: deliverableWorkflow.selection.confidence,
@@ -2691,6 +3175,7 @@ class AgentEngine {
2691
3175
  JSON.stringify(plan).slice(0, 20000)
2692
3176
  );
2693
3177
  this.persistRunMetadata(runId, { executionPlan: plan });
3178
+ this.updateRunGoalContract(runId, goalContractFromPlan(plan));
2694
3179
  this.emit(userId, 'run:plan', {
2695
3180
  runId,
2696
3181
  steps: plan.steps,
@@ -2699,6 +3184,13 @@ class AgentEngine {
2699
3184
  });
2700
3185
  }
2701
3186
 
3187
+ const runGoalContract = this.getRunMeta(runId)?.goalContract || null;
3188
+ if (runGoalContract) {
3189
+ messages.push({
3190
+ role: 'system',
3191
+ content: buildGoalContractPrompt(runGoalContract, 'Run goal contract'),
3192
+ });
3193
+ }
2702
3194
  messages.push({
2703
3195
  role: 'system',
2704
3196
  content: buildExecutionGuidance({
@@ -2954,6 +3446,43 @@ class AgentEngine {
2954
3446
  })) {
2955
3447
  break;
2956
3448
  }
3449
+ const runMetaAfterResponse = this.getRunMeta(runId);
3450
+ if (shouldRequireMessagingFinalityCheck(runMetaAfterResponse)) {
3451
+ const messagingCompletion = await this.resolveMessagingCompletionDecision({
3452
+ provider,
3453
+ providerName,
3454
+ model,
3455
+ messages,
3456
+ analysis,
3457
+ plan,
3458
+ tools,
3459
+ toolExecutions,
3460
+ lastReply: lastContent,
3461
+ iteration,
3462
+ maxIterations,
3463
+ runId,
3464
+ conversationId,
3465
+ options: { ...options, runId, userId, agentId },
3466
+ });
3467
+ totalTokens += messagingCompletion.usage || 0;
3468
+ if (messagingCompletion.action === 'continue') {
3469
+ messages.push({
3470
+ role: 'system',
3471
+ content: [
3472
+ messagingCompletion.reason
3473
+ ? `Continue working: ${messagingCompletion.reason}.`
3474
+ : 'Continue working autonomously.',
3475
+ 'The messaging user has already seen progress. Do not stop until you either have the finished answer now or a concrete blocker reply now.',
3476
+ ].join(' ')
3477
+ });
3478
+ lastContent = '';
3479
+ continue;
3480
+ }
3481
+ if (typeof messagingCompletion.content === 'string') {
3482
+ lastContent = messagingCompletion.content;
3483
+ }
3484
+ break;
3485
+ }
2957
3486
  if (iteration < maxIterations) {
2958
3487
  const proactiveRunNeedsDecision = (
2959
3488
  (triggerSource === 'schedule' || triggerSource === 'tasks')
@@ -3784,6 +4313,10 @@ class AgentEngine {
3784
4313
  ...(Array.isArray(options?.messagingRetryState?.interimHistory) ? options.messagingRetryState.interimHistory : []),
3785
4314
  ...(Array.isArray(runMeta?.interimMessages) ? runMeta.interimMessages : []),
3786
4315
  ]),
4316
+ goalContract: mergeGoalContracts(
4317
+ options?.messagingRetryState?.goalContract || null,
4318
+ runMeta?.goalContract || null,
4319
+ ),
3787
4320
  lastUserVisibleUpdateAt: runMeta?.progressLedger?.lastUserVisibleUpdateAt || options?.messagingRetryState?.lastUserVisibleUpdateAt || null,
3788
4321
  lastFinalDeliveryAt: runMeta?.progressLedger?.lastFinalDeliveryAt || options?.messagingRetryState?.lastFinalDeliveryAt || null,
3789
4322
  heartbeatCount: Number(runMeta?.progressLedger?.heartbeatCount || options?.messagingRetryState?.heartbeatCount || 0),