neoagent 3.2.1-beta.8 → 3.3.0

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 (70) hide show
  1. package/flutter_app/lib/main_app_shell.dart +178 -34
  2. package/flutter_app/lib/main_chat.dart +542 -80
  3. package/flutter_app/lib/main_controller.dart +59 -4
  4. package/flutter_app/lib/main_models.dart +171 -22
  5. package/flutter_app/lib/main_operations.dart +0 -49
  6. package/flutter_app/lib/main_settings.dart +338 -0
  7. package/flutter_app/lib/src/backend_client.dart +19 -0
  8. package/package.json +1 -1
  9. package/server/db/database.js +2 -2
  10. package/server/http/routes.js +1 -0
  11. package/server/public/.last_build_id +1 -1
  12. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  13. package/server/public/flutter_bootstrap.js +1 -1
  14. package/server/public/main.dart.js +85079 -83904
  15. package/server/routes/behavior.js +80 -0
  16. package/server/routes/settings.js +4 -0
  17. package/server/services/agents/manager.js +1 -1
  18. package/server/services/ai/history.js +1 -1
  19. package/server/services/ai/loop/agent_engine_core.js +131 -18
  20. package/server/services/ai/loop/blank_recovery.js +5 -4
  21. package/server/services/ai/loop/completion_judge.js +280 -18
  22. package/server/services/ai/loop/conversation_loop.js +92 -34
  23. package/server/services/ai/loop/messaging_delivery.js +47 -0
  24. package/server/services/ai/loop/progress_classification.js +2 -0
  25. package/server/services/ai/loopPolicy.js +24 -2
  26. package/server/services/ai/messagingFallback.js +17 -17
  27. package/server/services/ai/model_failure_cache.js +7 -0
  28. package/server/services/ai/systemPrompt.js +31 -122
  29. package/server/services/ai/taskAnalysis.js +101 -12
  30. package/server/services/ai/toolEvidence.js +198 -0
  31. package/server/services/ai/tools.js +60 -19
  32. package/server/services/behavior/config.js +251 -0
  33. package/server/services/behavior/defaults.js +68 -0
  34. package/server/services/behavior/delivery.js +176 -0
  35. package/server/services/behavior/index.js +43 -0
  36. package/server/services/behavior/model_client.js +35 -0
  37. package/server/services/behavior/modules/agent_identity.js +37 -0
  38. package/server/services/behavior/modules/channel_style.js +28 -0
  39. package/server/services/behavior/modules/index.js +29 -0
  40. package/server/services/behavior/modules/norms.js +101 -0
  41. package/server/services/behavior/modules/persona.js +172 -0
  42. package/server/services/behavior/modules/persona_prompt.js +238 -0
  43. package/server/services/behavior/modules/social_memory.js +86 -0
  44. package/server/services/behavior/modules/social_observability.js +94 -0
  45. package/server/services/behavior/modules/social_signals.js +41 -0
  46. package/server/services/behavior/modules/theory_of_mind.js +118 -0
  47. package/server/services/behavior/modules/turn_taking.js +238 -0
  48. package/server/services/behavior/pipeline.js +341 -0
  49. package/server/services/behavior/registry.js +78 -0
  50. package/server/services/behavior/signals.js +107 -0
  51. package/server/services/behavior/state.js +99 -0
  52. package/server/services/behavior/system_prompt.js +75 -0
  53. package/server/services/memory/manager.js +14 -33
  54. package/server/services/messaging/access_policy.js +269 -74
  55. package/server/services/messaging/automation.js +158 -27
  56. package/server/services/messaging/discord.js +11 -1
  57. package/server/services/messaging/formatting_guides.js +5 -4
  58. package/server/services/messaging/http_platforms.js +73 -28
  59. package/server/services/messaging/inbound_queue.js +74 -13
  60. package/server/services/messaging/inbound_store.js +33 -0
  61. package/server/services/messaging/manager.js +57 -5
  62. package/server/services/messaging/telegram.js +10 -1
  63. package/server/services/messaging/whatsapp.js +11 -0
  64. package/server/services/social_reach/channels/social_video.js +1 -1
  65. package/server/services/social_video/captions.js +2 -2
  66. package/server/services/social_video/service.js +194 -29
  67. package/server/services/voice/message.js +1 -1
  68. package/server/services/voice/runtime.js +2 -2
  69. package/server/utils/logger.js +19 -0
  70. package/server/services/ai/terminal_reply.js +0 -57
@@ -3,11 +3,10 @@
3
3
  const { normalizeCompletionConfidence } = require('../completion');
4
4
  const { normalizeOutgoingMessage } = require('../messagingFallback');
5
5
  const {
6
- isDeferredWorkReply,
7
- isTerminalQuestionOrBlockerReply,
8
- } = require('../terminal_reply');
9
- const {
6
+ assessResearchAdequacy,
7
+ formatResearchAdequacyGuidance,
10
8
  summarizeAvailableTools,
9
+ summarizeResearchEvidenceCatalog,
11
10
  summarizeToolExecutions,
12
11
  } = require('../toolEvidence');
13
12
 
@@ -198,12 +197,19 @@ function buildCompletionDecisionPrompt({
198
197
  lastReply,
199
198
  iteration,
200
199
  maxIterations,
200
+ analysis = null,
201
+ researchAdequacy = null,
201
202
  }) {
202
203
  const draftReply = normalizeOutgoingMessage(lastReply) || '';
204
+ const adequacy = researchAdequacy || assessResearchAdequacy({
205
+ analysis,
206
+ goalContext,
207
+ toolExecutions,
208
+ });
203
209
  const lines = [
204
210
  'Return JSON only.',
205
211
  'Decide whether this run should continue autonomously or stop now.',
206
- 'Schema: {"status":"continue|complete|blocked","reason":"short concrete reason"}',
212
+ 'Schema: {"status":"continue|complete|blocked","reason":"short concrete reason","blocker_review":{"kind":"user_input|permission|external_dependency|unavailable_capability|none","resolvable_in_run":true,"required_value":"specific missing dependency"},"research_review":{"required":true,"adequate":false,"overall_support":"primary|secondary|context|none","evidence_indexes":[1],"target_coverage":[{"target":"exact requested target","support":"primary|secondary|context|none","evidence_indexes":[1]}],"missing_targets":["exact missing target"]}}',
207
213
  'Rules:',
208
214
  '- Use "continue" whenever any safe next step remains in this same run.',
209
215
  '- Use "complete" when the requested outcome is achieved, already done, or a no-op because there is nothing matching the request to change, and the latest draft is the finished user-facing answer.',
@@ -214,13 +220,37 @@ function buildCompletionDecisionPrompt({
214
220
  '- 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.',
215
221
  '- Repeated read-only inspection that has already established the relevant object is absent or unchanged is not progress. Accept a concise complete/blocker reply instead of requiring more searching.',
216
222
  `- 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.`,
217
- triggerSource === 'messaging' && messagingSent
218
- ? '- A final reply was already delivered via send_message. Use "complete" unless concrete task work remains.'
219
- : triggerSource === 'messaging'
220
- ? '- For messaging, do not stop on a partial status message. Continue unless the task is actually complete or externally blocked.'
221
- : '- Do not stop just because you wrote a status update. Continue unless the task is actually complete or externally blocked.',
223
+ '- When research intensity is light or deep, fill research_review from the numbered evidence entries. Do not mark complete unless every requested target has concrete support.',
224
+ '- Search snippets, memory, and model priors are leads, not completion evidence. Prefer opened/fetched primary sources before "complete".',
225
+ '- Judge source quality semantically from the actual evidence summary. Tool names, call counts, local notes, and target words in tool arguments do not by themselves make evidence primary or prove a claim.',
226
+ '- evidence_indexes are the numeric parts of E identifiers from the Research evidence catalog. Never cite an entry that does not support the target.',
227
+ '- A real external blocker or genuinely required user input may be "blocked" even when research is incomplete. Name the missing dependency precisely; do not pretend more autonomous research can resolve user-only information.',
228
+ '- For "blocked", blocker_review.kind must identify the external dependency, resolvable_in_run must be false, and required_value must state what is missing. Otherwise choose "continue".',
229
+ '- If the latest draft invents entities, products, people, files, results, or actions that are not supported by tool evidence, use "continue" so the run can gather evidence or rewrite into a truthful partial/blocker answer.',
230
+ '- A polished-sounding answer is not complete if key requested targets still lack direct evidence.',
231
+ '- If the latest draft only announces unfinished work, promises a future update, or asks the user to wait without a concrete result or blocker, use "continue" so the run keeps acting.',
232
+ '- If the latest draft asks for missing required user input, confirmation, or a choice needed to proceed, use "blocked" so the run waits instead of repeating the same ask.',
222
233
  ];
223
234
 
235
+ if (adequacy.intensity !== 'none') {
236
+ lines.push(
237
+ `- Research intensity for this run is ${adequacy.intensity}. Successful evidence candidates=${adequacy.evidenceCandidateCount}; unique calls=${adequacy.uniqueEvidenceCandidateCount}. Semantic target coverage has not been precomputed.`,
238
+ );
239
+ }
240
+ if (adequacy.structurallyReady === false) {
241
+ lines.push(
242
+ `- No source evidence is ready for review (${adequacy.reason}). Use "continue" unless the latest draft is a real external blocker.`,
243
+ );
244
+ }
245
+
246
+ if (triggerSource === 'messaging' && messagingSent) {
247
+ lines.push('- A final reply was already delivered via send_message. Use "complete" unless concrete task work remains.');
248
+ } else if (triggerSource === 'messaging') {
249
+ lines.push('- For messaging, do not stop on a partial status message. Continue unless the task is actually complete or externally blocked.');
250
+ } else {
251
+ lines.push('- Do not stop just because you wrote a status update. Continue unless the task is actually complete or externally blocked.');
252
+ }
253
+
224
254
  lines.push(
225
255
  goalContext.effectiveGoal ? `Goal: ${goalContext.effectiveGoal}` : '',
226
256
  goalContext.persistedGoalPrompt,
@@ -230,32 +260,212 @@ function buildCompletionDecisionPrompt({
230
260
  : '',
231
261
  `Current iteration: ${iteration} of ${maxIterations}.`,
232
262
  `Available tools in this run: ${summarizeAvailableTools(tools) || 'none'}`,
233
- `Recent tool evidence:\n${summarizeToolExecutions(toolExecutions, 8) || 'none'}`,
263
+ `Recent tool evidence:\n${summarizeToolExecutions(toolExecutions, 12) || 'none'}`,
264
+ adequacy.intensity !== 'none'
265
+ ? `Research evidence catalog:\n${summarizeResearchEvidenceCatalog(toolExecutions) || 'none'}`
266
+ : '',
267
+ adequacy.intensity !== 'none'
268
+ ? `Research preflight: intensity=${adequacy.intensity}; structurally_ready=${adequacy.structurallyReady}; semantic_review_required=true; reason=${adequacy.reason}`
269
+ : '',
270
+ adequacy.targets.length
271
+ ? `Research targets: ${adequacy.targets.join('; ')}`
272
+ : '',
273
+ formatResearchAdequacyGuidance(adequacy),
234
274
  `Latest draft reply:\n${draftReply || '(empty)'}`,
235
275
  );
236
276
  return lines.filter(Boolean).join('\n');
237
277
  }
238
278
 
279
+ function normalizeEvidenceIndexes(value) {
280
+ if (!Array.isArray(value)) return [];
281
+ return [...new Set(value
282
+ .map((entry) => Number(entry))
283
+ .filter((entry) => Number.isInteger(entry) && entry > 0))]
284
+ .slice(0, 20);
285
+ }
286
+
287
+ function normalizeResearchReview(raw = null) {
288
+ const review = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
289
+ const targetCoverage = Array.isArray(review.target_coverage || review.targetCoverage)
290
+ ? (review.target_coverage || review.targetCoverage)
291
+ .filter((entry) => entry && typeof entry === 'object' && !Array.isArray(entry))
292
+ .map((entry) => ({
293
+ target: String(entry.target || '').trim(),
294
+ support: ['primary', 'secondary', 'context', 'none'].includes(
295
+ String(entry.support || '').trim().toLowerCase(),
296
+ )
297
+ ? String(entry.support).trim().toLowerCase()
298
+ : 'none',
299
+ evidence_indexes: normalizeEvidenceIndexes(
300
+ entry.evidence_indexes || entry.evidenceIndexes,
301
+ ),
302
+ }))
303
+ .filter((entry) => entry.target)
304
+ .slice(0, 12)
305
+ : [];
306
+ return {
307
+ required: review.required === true,
308
+ adequate: review.adequate === true,
309
+ overall_support: ['primary', 'secondary', 'context', 'none'].includes(
310
+ String(review.overall_support || review.overallSupport || '').trim().toLowerCase(),
311
+ )
312
+ ? String(review.overall_support || review.overallSupport).trim().toLowerCase()
313
+ : 'none',
314
+ evidence_indexes: normalizeEvidenceIndexes(
315
+ review.evidence_indexes || review.evidenceIndexes,
316
+ ),
317
+ target_coverage: targetCoverage,
318
+ missing_targets: normalizeGoalCriteria(
319
+ review.missing_targets || review.missingTargets || [],
320
+ ),
321
+ };
322
+ }
323
+
239
324
  function normalizeCompletionDecision(raw, fallbackStatus = 'continue') {
240
325
  const allowed = new Set(['continue', 'complete', 'blocked']);
241
326
  const requestedStatus = String(raw.status || '').trim().toLowerCase();
327
+ const rawBlockerReview = raw.blocker_review || raw.blockerReview;
328
+ const blockerReview = rawBlockerReview
329
+ && typeof rawBlockerReview === 'object'
330
+ && !Array.isArray(rawBlockerReview)
331
+ ? rawBlockerReview
332
+ : {};
333
+ const blockerKind = String(blockerReview.kind || '').trim().toLowerCase();
242
334
  return {
243
335
  status: allowed.has(requestedStatus) ? requestedStatus : fallbackStatus,
244
336
  reason: String(raw.reason || '').trim().slice(0, 400),
337
+ blocker_review: {
338
+ kind: [
339
+ 'user_input',
340
+ 'permission',
341
+ 'external_dependency',
342
+ 'unavailable_capability',
343
+ 'none',
344
+ ].includes(blockerKind)
345
+ ? blockerKind
346
+ : 'none',
347
+ resolvable_in_run: blockerReview.resolvable_in_run !== false
348
+ && blockerReview.resolvableInRun !== false,
349
+ required_value: String(
350
+ blockerReview.required_value || blockerReview.requiredValue || '',
351
+ ).trim().slice(0, 300),
352
+ },
353
+ research_review: normalizeResearchReview(
354
+ raw.research_review || raw.researchReview,
355
+ ),
356
+ };
357
+ }
358
+
359
+ function validateResearchReview(review, researchAdequacy, toolExecutions = []) {
360
+ if (!researchAdequacy || researchAdequacy.intensity === 'none') {
361
+ return { valid: true, reason: '' };
362
+ }
363
+ if (researchAdequacy.structurallyReady === false) {
364
+ return {
365
+ valid: false,
366
+ reason: researchAdequacy.reason || 'No successful source evidence is available.',
367
+ };
368
+ }
369
+ if (!review?.required || review.adequate !== true) {
370
+ return {
371
+ valid: false,
372
+ reason: 'The completion judge did not confirm adequate research evidence.',
373
+ };
374
+ }
375
+
376
+ const isValidEvidenceIndex = (index) => {
377
+ const execution = (Array.isArray(toolExecutions) ? toolExecutions : [])[index - 1];
378
+ return Boolean(
379
+ execution
380
+ && execution.ok === true
381
+ && execution.evidenceRelevant !== false
382
+ && execution.evidenceSource !== 'messaging',
383
+ );
245
384
  };
385
+ const hasValidEvidence = (indexes) => (
386
+ Array.isArray(indexes) && indexes.some(isValidEvidenceIndex)
387
+ );
388
+ const requiredTargets = Array.isArray(researchAdequacy.targets)
389
+ ? researchAdequacy.targets
390
+ : [];
391
+
392
+ if (requiredTargets.length === 0) {
393
+ const reviewIndexes = [
394
+ ...(review.evidence_indexes || []),
395
+ ...(review.target_coverage || []).flatMap((entry) => entry.evidence_indexes || []),
396
+ ];
397
+ if (!hasValidEvidence(reviewIndexes)) {
398
+ return { valid: false, reason: 'The research review cites no successful evidence entry.' };
399
+ }
400
+ if (researchAdequacy.intensity === 'deep' && review.overall_support !== 'primary') {
401
+ return { valid: false, reason: 'Deep research lacks primary source support.' };
402
+ }
403
+ if (review.overall_support === 'none') {
404
+ return { valid: false, reason: 'The research review did not classify its source support.' };
405
+ }
406
+ return { valid: true, reason: '' };
407
+ }
408
+
409
+ const coverageByTarget = new Map(
410
+ (review.target_coverage || []).map((entry) => [
411
+ String(entry.target || '').trim().toLowerCase(),
412
+ entry,
413
+ ]),
414
+ );
415
+ for (const target of requiredTargets) {
416
+ const coverage = coverageByTarget.get(String(target).trim().toLowerCase());
417
+ if (!coverage || coverage.support === 'none' || !hasValidEvidence(coverage.evidence_indexes)) {
418
+ return {
419
+ valid: false,
420
+ reason: `The research review does not cite supporting evidence for "${target}".`,
421
+ };
422
+ }
423
+ if (researchAdequacy.intensity === 'deep' && coverage.support !== 'primary') {
424
+ return {
425
+ valid: false,
426
+ reason: `Deep research still lacks primary evidence for "${target}".`,
427
+ };
428
+ }
429
+ }
430
+ return { valid: true, reason: '' };
246
431
  }
247
432
 
248
- function enforceTerminalReplyDecision(decision, lastReply) {
249
- if (isDeferredWorkReply(lastReply)) {
433
+ function enforceTerminalReplyDecision(decision, lastReply, options = {}) {
434
+ // Natural-language terminality is judged by the model completion decision.
435
+ // Runtime only enforces structural evidence contracts (research adequacy).
436
+ const researchAdequacy = options.researchAdequacy
437
+ || assessResearchAdequacy({
438
+ analysis: options.analysis || null,
439
+ goalContext: options.goalContext || null,
440
+ toolExecutions: options.toolExecutions || [],
441
+ });
442
+ if (decision?.status === 'blocked') {
443
+ const blockerReview = decision.blocker_review;
444
+ if (
445
+ blockerReview
446
+ && blockerReview.kind !== 'none'
447
+ && blockerReview.resolvable_in_run === false
448
+ && blockerReview.required_value
449
+ ) {
450
+ return decision;
451
+ }
250
452
  return {
453
+ ...decision,
251
454
  status: 'continue',
252
- reason: 'The latest reply only announces or promises unfinished work; the run must continue or return a concrete blocker.',
455
+ reason: 'The completion judge did not substantiate a blocker that requires external input or state.',
253
456
  };
254
457
  }
255
- if (decision?.status === 'continue' && isTerminalQuestionOrBlockerReply(lastReply)) {
458
+ if (decision?.status === 'complete') {
459
+ const reviewValidation = validateResearchReview(
460
+ decision.research_review,
461
+ researchAdequacy,
462
+ options.toolExecutions || [],
463
+ );
464
+ if (reviewValidation.valid) return decision;
256
465
  return {
257
- status: 'blocked',
258
- reason: 'The latest reply asks for user input or states a concrete blocker, so the run must wait instead of repeating it.',
466
+ status: 'continue',
467
+ reason: reviewValidation.reason,
468
+ research_review: decision.research_review,
259
469
  };
260
470
  }
261
471
  return decision;
@@ -269,11 +479,18 @@ function buildChurnAssessmentPrompt({
269
479
  goalContext,
270
480
  toolExecutions,
271
481
  iteration,
482
+ analysis = null,
483
+ researchAdequacy = null,
272
484
  }) {
485
+ const adequacy = researchAdequacy || assessResearchAdequacy({
486
+ analysis,
487
+ goalContext,
488
+ toolExecutions,
489
+ });
273
490
  const lines = [
274
491
  'Return JSON only.',
275
492
  'Self-assess your current loop state — are you making genuine progress or spinning?',
276
- 'Schema: {"assessment":"progressing|churn|blocked","reason":"one short concrete sentence"}',
493
+ 'Schema: {"assessment":"progressing|churn|blocked","reason":"one short concrete sentence","blocker_kind":"user_input|permission|external_dependency|unavailable_capability|none","resolvable_in_run":true}',
277
494
  '',
278
495
  `Context: ${readOnlyCount} consecutive iteration(s) with only read/search/inspect operations — no concrete state changes yet.`,
279
496
  alreadyRead ? `Already inspected: ${alreadyRead}.` : '',
@@ -283,21 +500,63 @@ function buildChurnAssessmentPrompt({
283
500
  : '',
284
501
  `Iteration: ${iteration}`,
285
502
  `Recent tool evidence:\n${summarizeToolExecutions(toolExecutions, 6) || 'none'}`,
503
+ adequacy.intensity !== 'none'
504
+ ? `Research preflight: intensity=${adequacy.intensity}; evidence_candidates=${adequacy.evidenceCandidateCount}; semantic target coverage must be judged from the evidence summaries.`
505
+ : '',
506
+ adequacy.targets.length
507
+ ? `Requested research targets: ${adequacy.targets.join('; ')}.`
508
+ : '',
286
509
  '',
287
510
  'Assessment rules:',
288
511
  '- "progressing": You are systematically gathering necessary context and the next concrete action is already determined — you know exactly what to do next.',
289
512
  '- "churn": You are re-reading/re-searching information already in context, or exploring without a clear next concrete step. Accept the nudge and act.',
290
513
  '- "blocked": No concrete action is available in this run. You have all the evidence needed to deliver a truthful final answer or a specific external blocker.',
514
+ '- Use "blocked" only with a non-none blocker_kind and resolvable_in_run=false. Partial evidence, a failed attempt, or uncertainty is not by itself an external blocker.',
515
+ '- For multi-target research, keep "progressing" while uncovered targets remain and a fresh primary source can still be opened. Do not mark "blocked" just because you have partial notes.',
516
+ '- Re-querying the same snippet source for an already covered target is "churn". Opening a different primary source for an uncovered target is "progressing".',
517
+ adequacy.structurallyReady === false
518
+ ? '- No successful source evidence exists yet. Prefer "progressing" if a concrete source-gathering step remains, but use "blocked" for a genuine external dependency or required user input.'
519
+ : '',
291
520
  ];
292
521
  return lines.filter(Boolean).join('\n');
293
522
  }
294
523
 
524
+ function enforceChurnAssessment(assessment) {
525
+ const normalized = normalizeChurnAssessment(assessment);
526
+ if (
527
+ normalized.assessment === 'blocked'
528
+ && (
529
+ normalized.blocker_kind === 'none'
530
+ || normalized.resolvable_in_run !== false
531
+ )
532
+ ) {
533
+ return {
534
+ ...normalized,
535
+ assessment: 'churn',
536
+ reason: 'The churn assessment did not identify a concrete external blocker.',
537
+ };
538
+ }
539
+ return normalized;
540
+ }
541
+
295
542
  function normalizeChurnAssessment(raw) {
296
543
  const allowed = new Set(['progressing', 'churn', 'blocked']);
297
544
  const assessment = String(raw?.assessment || '').trim().toLowerCase();
545
+ const blockerKind = String(raw?.blocker_kind || raw?.blockerKind || '').trim().toLowerCase();
298
546
  return {
299
547
  assessment: allowed.has(assessment) ? assessment : 'churn',
300
548
  reason: String(raw?.reason || '').trim().slice(0, 300),
549
+ blocker_kind: [
550
+ 'user_input',
551
+ 'permission',
552
+ 'external_dependency',
553
+ 'unavailable_capability',
554
+ 'none',
555
+ ].includes(blockerKind)
556
+ ? blockerKind
557
+ : 'none',
558
+ resolvable_in_run: raw?.resolvable_in_run !== false
559
+ && raw?.resolvableInRun !== false,
301
560
  };
302
561
  }
303
562
 
@@ -309,8 +568,11 @@ module.exports = {
309
568
  goalContractFromAnalysis,
310
569
  goalContractFromPlan,
311
570
  mergeGoalContracts,
571
+ enforceChurnAssessment,
312
572
  normalizeChurnAssessment,
313
573
  normalizeCompletionDecision,
574
+ normalizeResearchReview,
314
575
  normalizeGoalContract,
315
576
  resolveRunGoalContext,
577
+ validateResearchReview,
316
578
  };
@@ -153,8 +153,8 @@ const {
153
153
  buildDeterministicMessagingErrorReply,
154
154
  buildModelFailureLoopPrompt,
155
155
  } = require('../messagingFallback');
156
- const { isDeferredWorkReply } = require('../terminal_reply');
157
156
  const {
157
+ assessResearchAdequacy,
158
158
  classifyToolExecution,
159
159
  gatheredNewEvidence,
160
160
  isSubstantiveProgressToolName,
@@ -303,13 +303,34 @@ function summarizeReadTargets(toolExecutions = []) {
303
303
  return targets.join('; ');
304
304
  }
305
305
 
306
- function buildNoProgressWrapupPrompt({ readOnlyCount = 0, alreadyRead = '', platform = null } = {}) {
306
+ function buildNoProgressWrapupPrompt({
307
+ readOnlyCount = 0,
308
+ alreadyRead = '',
309
+ platform = null,
310
+ researchAdequacy = null,
311
+ } = {}) {
312
+ const adequacy = researchAdequacy && typeof researchAdequacy === 'object'
313
+ ? researchAdequacy
314
+ : null;
315
+ const researchIncomplete = adequacy
316
+ && adequacy.adequate === false
317
+ && adequacy.intensity
318
+ && adequacy.intensity !== 'none';
307
319
  return [
308
320
  `This is the final turn for this run (no further tool calls; ${Math.max(0, Number(readOnlyCount) || 0)} read-only turns without a state change).`,
309
321
  alreadyRead ? `Already gathered: ${alreadyRead}.` : '',
322
+ researchIncomplete
323
+ ? `Research coverage is incomplete: ${adequacy.reason || 'requested targets still lack primary evidence.'}`
324
+ : '',
325
+ researchIncomplete && adequacy.uncoveredTargets?.length
326
+ ? `Uncovered research targets: ${adequacy.uncoveredTargets.join('; ')}.`
327
+ : '',
310
328
  'Write the answer now from everything you have already gathered in this conversation. Deliver the useful result you DO have — calendar, weather, emails, search findings, whatever was collected — formatted as the actual answer to the original request.',
311
329
  'If one part could not be retrieved, still deliver everything else and note the missing part in at most one short clause. Never withhold a useful answer because a single detail is missing.',
312
- 'Only report a pure blocker if you genuinely gathered nothing usable at all. Do not describe the result as unfinished, unconfirmed, "blocked", or "still working" when you have something useful this IS the final answer.',
330
+ 'Do not invent entities, products, people, files, outcomes, or next-step work that is not already supported by tool evidence in this conversation.',
331
+ researchIncomplete
332
+ ? 'Because research is incomplete, clearly label assumptions and missing targets. Prefer a partial verified answer or a concrete blocker over a confident fabricated comparison.'
333
+ : 'Only report a pure blocker if you genuinely gathered nothing usable at all. Do not describe the result as unfinished, unconfirmed, "blocked", or "still working" when you have something useful — this IS the final answer.',
313
334
  buildMaxIterationWrapupPrompt(platform),
314
335
  ].filter(Boolean).join('\n\n');
315
336
  }
@@ -770,6 +791,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
770
791
  ? {
771
792
  platform: options.source || null,
772
793
  chatId: options.chatId || null,
794
+ behavior: options.context?.socialIntelligence || null,
773
795
  }
774
796
  : null,
775
797
  goalContract: carriedGoalContract,
@@ -784,7 +806,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
784
806
  progressLedger,
785
807
  goalContract: carriedGoalContract,
786
808
  });
787
- engine.startMessagingProgressSupervisor(runId);
809
+ if (options.context?.socialIntelligence?.isGroup !== true) {
810
+ engine.startMessagingProgressSupervisor(runId);
811
+ }
788
812
  engine.emit(userId, 'run:start', {
789
813
  runId,
790
814
  agentId,
@@ -821,6 +845,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
821
845
  userMessage,
822
846
  agentId,
823
847
  triggerSource,
848
+ memoryAudience: options.memoryAudience || 'owner',
824
849
  });
825
850
  // Pass short descriptions so the model always knows every available tool.
826
851
  // compactToolDefinition caps tool desc at 120 chars, param desc at 70 chars.
@@ -971,7 +996,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
971
996
  // Reuse the run's real system prompt so the update follows the same voice and
972
997
  // formatting guidelines as every other message (single source of truth).
973
998
  const sysContent = [systemPrompt?.stable, systemPrompt?.dynamic].filter(Boolean).join('\n\n')
974
- || 'You are a helpful assistant.';
999
+ || 'Respond directly and accurately using only the verified progress evidence.';
975
1000
  const resp = await runAbortableModelCall(
976
1001
  (signal) => provider.chat(
977
1002
  [
@@ -1373,6 +1398,47 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1373
1398
  continue;
1374
1399
  }
1375
1400
  alreadyRead = summarizeReadTargets(toolExecutions);
1401
+ const researchAdequacyAtCap = assessResearchAdequacy({
1402
+ analysis,
1403
+ goalContext: runGoalCtx,
1404
+ toolExecutions,
1405
+ });
1406
+ // Incomplete multi-target research is not "stuck" yet if primary sources
1407
+ // remain uncovered. Give one guided research continuation instead of
1408
+ // force-finishing with a guessed comparison.
1409
+ if (
1410
+ researchAdequacyAtCap.adequate === false
1411
+ && researchAdequacyAtCap.intensity !== 'none'
1412
+ && iterMeta
1413
+ && iterMeta.researchAdequacyDeferralUsed !== true
1414
+ && (
1415
+ researchAdequacyAtCap.uncoveredTargets.length > 0
1416
+ || researchAdequacyAtCap.primarySourceCount < researchAdequacyAtCap.requiredPrimarySources
1417
+ )
1418
+ ) {
1419
+ iterMeta.researchAdequacyDeferralUsed = true;
1420
+ iterMeta.consecutiveReadOnlyIterations = Math.max(0, churnNudgeThreshold - 1);
1421
+ messages.push({
1422
+ role: 'system',
1423
+ content: [
1424
+ 'Research self-check: evidence is still incomplete, so this run must not force-finish yet.',
1425
+ researchAdequacyAtCap.reason ? `Gap: ${researchAdequacyAtCap.reason}` : '',
1426
+ researchAdequacyAtCap.nextActions?.length
1427
+ ? `Next safe steps:\n- ${researchAdequacyAtCap.nextActions.join('\n- ')}`
1428
+ : '',
1429
+ 'Open or fetch primary sources for uncovered targets now. Do not invent the missing comparison from memory or search snippets alone.',
1430
+ ].filter(Boolean).join('\n'),
1431
+ });
1432
+ engine.recordRunEvent(userId, runId, 'read_only_wrapup_deferred_for_research', {
1433
+ iteration,
1434
+ readOnlyCount,
1435
+ intensity: researchAdequacyAtCap.intensity,
1436
+ uncoveredTargets: researchAdequacyAtCap.uncoveredTargets,
1437
+ primarySourceCount: researchAdequacyAtCap.primarySourceCount,
1438
+ requiredPrimarySources: researchAdequacyAtCap.requiredPrimarySources,
1439
+ }, { agentId });
1440
+ continue;
1441
+ }
1376
1442
  triggerForceWrapup = true;
1377
1443
  } else if (readOnlyCount >= churnNudgeThreshold) {
1378
1444
  alreadyRead = summarizeReadTargets(toolExecutions);
@@ -1447,6 +1513,11 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1447
1513
  readOnlyCount,
1448
1514
  alreadyRead,
1449
1515
  platform: options?.source || null,
1516
+ researchAdequacy: assessResearchAdequacy({
1517
+ analysis,
1518
+ goalContext: runGoalCtx,
1519
+ toolExecutions,
1520
+ }),
1450
1521
  }),
1451
1522
  },
1452
1523
  ]),
@@ -1466,8 +1537,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1466
1537
  console.warn(`[Run ${shortenRunId(runId)}] no_progress_wrapup failed: ${summarizeForLog(wrapErr?.message || wrapErr, 180)}`);
1467
1538
  }
1468
1539
  totalTokens += wrapTokens;
1469
- const usableWrap = normalizeOutgoingMessage(lastContent, options?.source || null)
1470
- && !isDeferredWorkReply(lastContent);
1540
+ const usableWrap = normalizeOutgoingMessage(lastContent, options?.source || null);
1471
1541
  if (!usableWrap) {
1472
1542
  lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1473
1543
  }
@@ -1697,15 +1767,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1697
1767
  lastContent = '';
1698
1768
  continue;
1699
1769
  }
1700
- if (engine.shouldFastCompleteVoiceReply({
1701
- options,
1702
- toolExecutions,
1703
- failedStepCount,
1704
- messagingSent: engine.activeRuns.get(runId)?.messagingSent || false,
1705
- lastReply: lastContent,
1706
- })) {
1707
- break;
1708
- }
1709
1770
  if (shouldContinueAfterBlankToolFailure({
1710
1771
  lastContent,
1711
1772
  failedStepCount,
@@ -1761,13 +1822,23 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1761
1822
  iteration,
1762
1823
  }, { agentId });
1763
1824
  if (loopDecision.decision.status === 'continue') {
1825
+ const researchGuidance = loopDecision.researchAdequacy?.adequate === false
1826
+ ? [
1827
+ `Research still incomplete: ${loopDecision.researchAdequacy.reason || 'gather primary evidence for remaining targets.'}`,
1828
+ loopDecision.researchAdequacy.nextActions?.length
1829
+ ? `Next safe steps: ${loopDecision.researchAdequacy.nextActions.join(' ')}`
1830
+ : '',
1831
+ ].filter(Boolean).join(' ')
1832
+ : '';
1764
1833
  messages.push({
1765
1834
  role: 'system',
1766
1835
  content: [
1767
1836
  'The run self-check determined the latest assistant text is not terminal.',
1768
1837
  'Continue with the next safe tool/model step.',
1769
1838
  'If the text was only a progress note, do not repeat it; either make progress or provide a real final/blocker reply.',
1770
- ].join(' '),
1839
+ 'Do not invent missing targets, outcomes, or tool results. Gather evidence or return a truthful partial/blocker answer.',
1840
+ researchGuidance,
1841
+ ].filter(Boolean).join(' '),
1771
1842
  });
1772
1843
  lastContent = '';
1773
1844
  continue;
@@ -2399,8 +2470,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2399
2470
  // On budget exhaustion the model's last text is an untrustworthy mid-thought
2400
2471
  // fragment. Replace it with the wrap-up answer, or a clean deterministic
2401
2472
  // fallback if the wrap-up came back empty — never deliver the fragment.
2402
- const usableWrap = normalizeOutgoingMessage(wrapText, options?.source || null)
2403
- && !isDeferredWorkReply(wrapText);
2473
+ const usableWrap = normalizeOutgoingMessage(wrapText, options?.source || null);
2404
2474
  lastContent = usableWrap
2405
2475
  ? wrapText
2406
2476
  : buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
@@ -2455,8 +2525,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2455
2525
  // evidence) instead of second-guessing it into a generic blob. Only fall back to
2456
2526
  // a deterministic message when the model returned nothing usable.
2457
2527
  const recoveredVisible = Boolean(
2458
- normalizeOutgoingMessage(lastContent, options?.source || null)
2459
- && !isDeferredWorkReply(lastContent),
2528
+ normalizeOutgoingMessage(lastContent, options?.source || null),
2460
2529
  );
2461
2530
  if (!recoveredVisible) {
2462
2531
  lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
@@ -2603,18 +2672,8 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2603
2672
  });
2604
2673
  }
2605
2674
 
2606
- if (!messagingSent && isDeferredWorkReply(finalResponseText)) {
2607
- engine.recordRunEvent(userId, runId, 'non_terminal_final_reply_rejected', {
2608
- iteration,
2609
- contentPreview: String(finalResponseText || '').slice(0, 240),
2610
- }, { agentId });
2611
- finalResponseText = buildDeterministicMessagingFallback({
2612
- failedStepCount,
2613
- stepIndex,
2614
- toolExecutions,
2615
- });
2616
- lastContent = finalResponseText;
2617
- }
2675
+ // Final reply terminality is decided by the AI completion judge / task_complete path.
2676
+ // Do not phrase-match natural-language drafts here.
2618
2677
 
2619
2678
  if (deliverableWorkflow && deliverablePlan) {
2620
2679
  engine.recordRunEvent(userId, runId, 'deliverable_validation_started', {
@@ -2883,7 +2942,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2883
2942
  const drafted = sanitizeModelOutput(modelReply.content || '', { model });
2884
2943
  if (
2885
2944
  normalizeOutgoingMessage(drafted, options?.source || null)
2886
- && !isDeferredWorkReply(drafted)
2887
2945
  ) {
2888
2946
  messagingFailureContent = drafted.trim();
2889
2947
  }