neoagent 3.2.1-beta.0 → 3.2.1-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.
Files changed (175) hide show
  1. package/docs/agent-run-lifecycle.md +10 -0
  2. package/extensions/chrome-browser/background.mjs +318 -88
  3. package/extensions/chrome-browser/http.mjs +136 -0
  4. package/extensions/chrome-browser/protocol.mjs +654 -90
  5. package/flutter_app/lib/main_chat.dart +118 -739
  6. package/flutter_app/lib/main_controller.dart +111 -20
  7. package/flutter_app/lib/main_integrations.dart +607 -8
  8. package/flutter_app/lib/main_models.dart +3 -0
  9. package/flutter_app/lib/main_operations.dart +334 -321
  10. package/flutter_app/lib/main_security.dart +266 -112
  11. package/flutter_app/lib/main_settings.dart +4 -3
  12. package/flutter_app/lib/main_shared.dart +14 -11
  13. package/flutter_app/lib/src/backend_client.dart +78 -0
  14. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  15. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  16. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  17. package/landing/index.html +3 -1
  18. package/lib/manager.js +106 -89
  19. package/lib/schema_migrations.js +145 -13
  20. package/package.json +30 -15
  21. package/runtime/paths.js +49 -5
  22. package/server/db/database.js +4 -4
  23. package/server/guest-agent.cli.package.json +13 -0
  24. package/server/guest_agent.js +85 -40
  25. package/server/http/middleware.js +24 -0
  26. package/server/http/routes.js +11 -6
  27. package/server/public/.last_build_id +1 -1
  28. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  29. package/server/public/flutter_bootstrap.js +2 -2
  30. package/server/public/main.dart.js +73083 -72209
  31. package/server/routes/admin.js +1 -1
  32. package/server/routes/agents.js +35 -2
  33. package/server/routes/android.js +30 -34
  34. package/server/routes/browser.js +23 -15
  35. package/server/routes/desktop.js +18 -1
  36. package/server/routes/integrations.js +107 -1
  37. package/server/routes/memory.js +1 -0
  38. package/server/routes/settings.js +16 -5
  39. package/server/routes/social_reach.js +12 -3
  40. package/server/routes/social_video.js +4 -0
  41. package/server/services/agents/manager.js +1 -1
  42. package/server/services/ai/capabilityHealth.js +62 -96
  43. package/server/services/ai/compaction.js +7 -2
  44. package/server/services/ai/history.js +45 -6
  45. package/server/services/ai/integrated_tools/http_request.js +8 -0
  46. package/server/services/ai/loop/agent_engine_core.js +496 -166
  47. package/server/services/ai/loop/blank_recovery.js +5 -4
  48. package/server/services/ai/loop/callbacks.js +1 -0
  49. package/server/services/ai/loop/completion_judge.js +121 -5
  50. package/server/services/ai/loop/conversation_loop.js +620 -340
  51. package/server/services/ai/loop/lifecycle.js +108 -0
  52. package/server/services/ai/loop/messaging_delivery.js +129 -57
  53. package/server/services/ai/loop/model_call_guard.js +91 -0
  54. package/server/services/ai/loop/model_io.js +48 -56
  55. package/server/services/ai/loop/progress_classification.js +2 -0
  56. package/server/services/ai/loop/tool_dispatch.js +28 -8
  57. package/server/services/ai/loopPolicy.js +48 -21
  58. package/server/services/ai/messagingFallback.js +17 -17
  59. package/server/services/ai/model_discovery.js +227 -0
  60. package/server/services/ai/model_failure_cache.js +108 -0
  61. package/server/services/ai/model_identity.js +71 -0
  62. package/server/services/ai/models.js +68 -163
  63. package/server/services/ai/providerRetry.js +17 -59
  64. package/server/services/ai/provider_selector.js +166 -0
  65. package/server/services/ai/providers/anthropic.js +4 -4
  66. package/server/services/ai/providers/claudeCode.js +21 -33
  67. package/server/services/ai/providers/githubCopilot.js +41 -20
  68. package/server/services/ai/providers/google.js +135 -97
  69. package/server/services/ai/providers/grok.js +6 -5
  70. package/server/services/ai/providers/grokOauth.js +19 -27
  71. package/server/services/ai/providers/nvidia.js +12 -7
  72. package/server/services/ai/providers/ollama.js +114 -86
  73. package/server/services/ai/providers/ollama_stream.js +142 -0
  74. package/server/services/ai/providers/openai.js +41 -7
  75. package/server/services/ai/providers/openaiCodex.js +13 -4
  76. package/server/services/ai/providers/openrouter.js +31 -9
  77. package/server/services/ai/providers/provider_error.js +36 -0
  78. package/server/services/ai/settings.js +26 -2
  79. package/server/services/ai/systemPrompt.js +19 -12
  80. package/server/services/ai/taskAnalysis.js +58 -10
  81. package/server/services/ai/terminal_reply.js +18 -0
  82. package/server/services/ai/toolEvidence.js +350 -29
  83. package/server/services/ai/tools.js +190 -111
  84. package/server/services/android/controller.js +770 -237
  85. package/server/services/android/process.js +140 -0
  86. package/server/services/android/sdk_download.js +143 -0
  87. package/server/services/android/uia.js +6 -5
  88. package/server/services/artifacts/store.js +24 -0
  89. package/server/services/browser/controller.js +843 -385
  90. package/server/services/browser/extension/gateway.js +40 -16
  91. package/server/services/browser/extension/protocol.js +15 -1
  92. package/server/services/browser/extension/provider.js +71 -47
  93. package/server/services/browser/extension/registry.js +155 -34
  94. package/server/services/cli/executor.js +62 -9
  95. package/server/services/credentials/bitwarden_cli.js +322 -0
  96. package/server/services/credentials/broker.js +594 -0
  97. package/server/services/desktop/gateway.js +41 -4
  98. package/server/services/desktop/protocol.js +3 -0
  99. package/server/services/desktop/provider.js +39 -42
  100. package/server/services/desktop/registry.js +137 -52
  101. package/server/services/integrations/bitwarden/constants.js +14 -0
  102. package/server/services/integrations/bitwarden/provider.js +197 -0
  103. package/server/services/integrations/bitwarden/snapshot.js +65 -0
  104. package/server/services/integrations/figma/provider.js +78 -12
  105. package/server/services/integrations/github/common.js +11 -6
  106. package/server/services/integrations/github/provider.js +52 -53
  107. package/server/services/integrations/google/provider.js +55 -19
  108. package/server/services/integrations/home_assistant/network.js +17 -20
  109. package/server/services/integrations/home_assistant/provider.js +7 -5
  110. package/server/services/integrations/home_assistant/tools.js +17 -5
  111. package/server/services/integrations/http.js +51 -0
  112. package/server/services/integrations/manager.js +159 -53
  113. package/server/services/integrations/microsoft/provider.js +80 -13
  114. package/server/services/integrations/neoarchive/provider.js +55 -29
  115. package/server/services/integrations/neorecall/client.js +17 -10
  116. package/server/services/integrations/neorecall/provider.js +20 -11
  117. package/server/services/integrations/notion/provider.js +16 -13
  118. package/server/services/integrations/oauth_provider.js +115 -51
  119. package/server/services/integrations/registry.js +2 -0
  120. package/server/services/integrations/slack/provider.js +98 -9
  121. package/server/services/integrations/spotify/provider.js +67 -71
  122. package/server/services/integrations/trello/provider.js +21 -7
  123. package/server/services/integrations/weather/provider.js +18 -12
  124. package/server/services/integrations/whatsapp/provider.js +76 -16
  125. package/server/services/manager.js +110 -1
  126. package/server/services/memory/embedding_index.js +20 -8
  127. package/server/services/memory/embeddings.js +151 -90
  128. package/server/services/memory/ingestion.js +50 -9
  129. package/server/services/memory/ingestion_documents.js +13 -3
  130. package/server/services/memory/manager.js +52 -19
  131. package/server/services/messaging/automation.js +85 -10
  132. package/server/services/messaging/formatting_guides.js +7 -4
  133. package/server/services/messaging/http_platforms.js +33 -13
  134. package/server/services/messaging/inbound_queue.js +78 -24
  135. package/server/services/messaging/inbound_store.js +224 -0
  136. package/server/services/messaging/manager.js +326 -51
  137. package/server/services/messaging/typing_keepalive.js +5 -2
  138. package/server/services/messaging/whatsapp.js +22 -14
  139. package/server/services/network/http.js +210 -0
  140. package/server/services/network/safe_request.js +307 -0
  141. package/server/services/runtime/backends/local-vm.js +227 -67
  142. package/server/services/runtime/docker-vm-manager.js +9 -0
  143. package/server/services/runtime/guest_bootstrap.js +30 -4
  144. package/server/services/runtime/guest_image.js +43 -12
  145. package/server/services/runtime/manager.js +77 -23
  146. package/server/services/runtime/validation.js +7 -6
  147. package/server/services/security/tool_categories.js +6 -0
  148. package/server/services/social_reach/channels/github.js +10 -4
  149. package/server/services/social_reach/channels/reddit.js +4 -4
  150. package/server/services/social_reach/channels/rss.js +2 -2
  151. package/server/services/social_reach/channels/social_video.js +12 -7
  152. package/server/services/social_reach/channels/v2ex.js +21 -8
  153. package/server/services/social_reach/channels/x.js +2 -2
  154. package/server/services/social_reach/channels/xueqiu.js +5 -5
  155. package/server/services/social_reach/service.js +9 -6
  156. package/server/services/social_reach/utils.js +65 -14
  157. package/server/services/social_video/service.js +160 -50
  158. package/server/services/tasks/integration_runtime.js +18 -8
  159. package/server/services/tasks/runtime.js +39 -4
  160. package/server/services/voice/agentBridge.js +17 -4
  161. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  162. package/server/services/voice/liveSession.js +31 -0
  163. package/server/services/voice/message.js +1 -1
  164. package/server/services/voice/openaiSpeech.js +33 -8
  165. package/server/services/voice/providers.js +233 -151
  166. package/server/services/voice/runtime.js +2 -2
  167. package/server/services/voice/runtimeManager.js +118 -20
  168. package/server/services/voice/turnRunner.js +6 -0
  169. package/server/services/wearable/firmware_manifest.js +51 -13
  170. package/server/services/wearable/service.js +1 -0
  171. package/server/utils/abort.js +96 -0
  172. package/server/utils/cloud-security.js +110 -3
  173. package/server/utils/files.js +31 -0
  174. package/server/utils/image_payload.js +95 -0
  175. package/server/utils/retry.js +107 -0
@@ -182,7 +182,9 @@ function buildReadOnlyChurnGuidance({ readOnlyCount = 0, alreadyRead = '' } = {}
182
182
  : 'Do not re-read or re-search anything already in this conversation.',
183
183
  'Decide from the evidence you have now.',
184
184
  'If the requested work is already done, no matching target exists, or the available tools cannot make the change, call task_complete with that truthful final answer or blocker.',
185
+ 'If research targets remain uncovered, open a new primary source for one uncovered target now instead of re-querying the same lead.',
185
186
  'If exactly one concrete safe action remains, take that action now. Otherwise finish; more poking around is not progress.',
187
+ 'Never invent missing targets, products, people, files, or outcomes to make the answer look complete.',
186
188
  ].join(' ');
187
189
  }
188
190
 
@@ -4,7 +4,10 @@ const { v4: uuidv4 } = require('uuid');
4
4
  const db = require('../../../db/database');
5
5
  const { globalHooks } = require('../hooks');
6
6
  const { summarizeForLog } = require('../logFormat');
7
- const { inferToolFailureMessage } = require('../toolEvidence');
7
+ const {
8
+ inferToolFailureMessage,
9
+ resolveDeclaredToolAccess,
10
+ } = require('../toolEvidence');
8
11
 
9
12
  function getAvailableTools(_engine, app, options = {}) {
10
13
  const { getAvailableTools: loadAvailableTools } = require('../tools');
@@ -16,8 +19,21 @@ async function executeTool(engine, toolName, args, context) {
16
19
  return runTool(toolName, args, context, engine);
17
20
  }
18
21
 
19
- function isReadOnlyToolCall(toolCall) {
22
+ function isReadOnlyToolCall(toolCall, toolDefinition = null) {
20
23
  const name = String(toolCall?.function?.name || '');
24
+ let toolArgs = {};
25
+ try {
26
+ toolArgs = typeof toolCall?.function?.arguments === 'string'
27
+ ? JSON.parse(toolCall.function.arguments || '{}')
28
+ : (toolCall?.function?.arguments || {});
29
+ } catch {
30
+ return false;
31
+ }
32
+
33
+ const declaredAccess = resolveDeclaredToolAccess(toolDefinition, toolArgs);
34
+ if (declaredAccess === 'read') return true;
35
+ if (declaredAccess === 'write') return false;
36
+
21
37
  const readOnly = new Set([
22
38
  'read_file',
23
39
  'read_files',
@@ -35,12 +51,7 @@ function isReadOnlyToolCall(toolCall) {
35
51
  'read_health_data',
36
52
  ]);
37
53
  if (name === 'http_request') {
38
- try {
39
- const args = JSON.parse(toolCall.function.arguments || '{}');
40
- return String(args.method || 'GET').toUpperCase() === 'GET';
41
- } catch {
42
- return false;
43
- }
54
+ return String(toolArgs.method || 'GET').toUpperCase() === 'GET';
44
55
  }
45
56
  return readOnly.has(name);
46
57
  }
@@ -145,6 +156,14 @@ async function executeReadOnlyBatch(engine, toolCalls, context = {}) {
145
156
  }, { agentId });
146
157
  const results = await Promise.all(prepared.map(async (item) => {
147
158
  if (item.blocked) return item;
159
+ const runMeta = engine.getRunMeta(runId);
160
+ if (!runMeta || runMeta.status !== 'running') {
161
+ const result = { status: 'paused', reason: 'Run paused before this read-only call started.' };
162
+ db.prepare(
163
+ `UPDATE agent_steps SET status = 'paused', result = ?, completed_at = datetime('now') WHERE id = ? AND status = 'running'`,
164
+ ).run(JSON.stringify(result), item.stepId);
165
+ return { ...item, result };
166
+ }
148
167
  const startedAt = Date.now();
149
168
  try {
150
169
  const result = await executeTool(engine, item.toolName, item.toolArgs, {
@@ -162,6 +181,7 @@ async function executeReadOnlyBatch(engine, toolCalls, context = {}) {
162
181
  deliveryState: options.deliveryState || null,
163
182
  allowMultipleProactiveMessages: options.allowMultipleProactiveMessages === true,
164
183
  allowExternalSideEffects: false,
184
+ signal: runMeta.abortController?.signal,
165
185
  });
166
186
  const error = inferToolFailureMessage(item.toolName, result);
167
187
  const status = error ? 'failed' : 'completed';
@@ -1,21 +1,24 @@
1
- // Limits resolve in priority order: per-run options → agent AI settings → hardcoded defaults.
1
+ // Limits resolve in priority order: per-run options → agent AI settings → conservative defaults.
2
2
  // They are safety nets only; task_complete / progress guards are the real exit signals.
3
3
 
4
4
  // The iteration ceiling is a pure runaway safety net, NOT the primary stop signal.
5
5
  // A run stops when it makes no real progress (consecutiveReadOnlyIterations cap,
6
- // which resets the moment the agent does anything state-changing), or when the
6
+ // which resets on a state change or genuinely new evidence), or when the
7
7
  // repetition / tool-failure / model-recovery guards fire, or when the model signals
8
8
  // task_complete. These ceilings are set high so they only ever catch a genuine
9
9
  // runaway and never guillotine a long, legitimately-progressing complex task.
10
10
  const DEFAULT_MAX_ITERATIONS = 250;
11
+ const DEFAULT_SIMPLE_MAX_ITERATIONS = 16;
11
12
  const DEFAULT_WIDGET_MAX_ITERATIONS = 150;
12
13
  const DEFAULT_PLAN_EXECUTE_MAX_ITERATIONS = 250;
13
14
  // Less aggressive than 0.60 so the model retains file contents it already read for
14
15
  // longer, instead of losing them to compaction and re-reading the same files.
15
16
  const DEFAULT_COMPACTION_THRESHOLD = 0.80;
16
- // The real "stop when stuck" guard. Counts consecutive turns of pure reading/
17
- // searching/inspecting with zero state change; resets to 0 on any concrete progress.
17
+ // The real "stop when stuck" guard. Counts consecutive turns with no state
18
+ // change and no new evidence; resets to 0 on any concrete progress.
18
19
  const DEFAULT_MAX_CONSECUTIVE_READ_ONLY_ITERATIONS = 8;
20
+ const DEFAULT_SIMPLE_MAX_CONSECUTIVE_READ_ONLY_ITERATIONS = 3;
21
+ const DEFAULT_COMPLEX_MAX_CONSECUTIVE_READ_ONLY_ITERATIONS = 14;
19
22
  const DEFAULT_MAX_CONSECUTIVE_TOOL_FAILURES = 5;
20
23
  const DEFAULT_MAX_MODEL_FAILURE_RECOVERIES = 3;
21
24
 
@@ -25,8 +28,9 @@ const MAX_ALLOWED_TOOL_FAILURES = 50;
25
28
  const MAX_ALLOWED_MODEL_RECOVERIES = 10;
26
29
  const MAX_ALLOWED_BUDGET_CHARS = 500_000;
27
30
 
28
- function finitePositive(n, fallback) {
29
- return Number.isFinite(n) && n > 0 ? n : fallback;
31
+ function optionalNumber(value) {
32
+ if (value == null || value === '') return Number.NaN;
33
+ return Number(value);
30
34
  }
31
35
 
32
36
  function clampFinite(n, lo, hi, fallback) {
@@ -56,6 +60,10 @@ function buildLoopPolicy(aiSettings = {}, triggerType = 'chat', analysisMode = '
56
60
  rawIterations = DEFAULT_PLAN_EXECUTE_MAX_ITERATIONS;
57
61
  } else if (complexity === 'complex' || autonomyLevel === 'high') {
58
62
  rawIterations = DEFAULT_PLAN_EXECUTE_MAX_ITERATIONS;
63
+ } else if (analysisMode === 'direct_answer' || complexity === 'simple') {
64
+ // Short Q&A / casual chat must stay cheap. This is a hard runaway cap, not
65
+ // a target: direct answers usually finish in 0-1 model turns.
66
+ rawIterations = DEFAULT_SIMPLE_MAX_ITERATIONS;
59
67
  } else if (parallelWork || complexity === 'standard') {
60
68
  rawIterations = Math.max(DEFAULT_MAX_ITERATIONS, 28);
61
69
  } else {
@@ -70,41 +78,58 @@ function buildLoopPolicy(aiSettings = {}, triggerType = 'chat', analysisMode = '
70
78
 
71
79
  // ── Tool result size budget ───────────────────────────────────────────────
72
80
  // Must be a finite positive integer; bad values fall back to 2400.
73
- const defaultBudget = clampFinite(
74
- Math.floor(Number(aiSettings.tool_replay_budget_chars) || 0),
75
- 500,
76
- MAX_ALLOWED_BUDGET_CHARS,
77
- 2400,
78
- );
81
+ const requestedDefaultBudget = optionalNumber(aiSettings.tool_replay_budget_chars);
82
+ const defaultBudget = Number.isFinite(requestedDefaultBudget) && requestedDefaultBudget > 0
83
+ ? clampFinite(Math.floor(requestedDefaultBudget), 500, MAX_ALLOWED_BUDGET_CHARS, 2400)
84
+ : 2400;
79
85
 
80
86
  // ── Scalar policy fields ─────────────────────────────────────────────────
81
87
  const maxConsecutiveToolFailures = clampFinite(
82
- Math.floor(Number(aiSettings.max_consecutive_tool_failures)),
88
+ Math.floor(optionalNumber(
89
+ options.maxConsecutiveToolFailures
90
+ ?? aiSettings.max_consecutive_tool_failures,
91
+ )),
83
92
  1,
84
93
  MAX_ALLOWED_TOOL_FAILURES,
85
94
  DEFAULT_MAX_CONSECUTIVE_TOOL_FAILURES,
86
95
  );
87
96
 
88
97
  const maxModelFailureRecoveries = clampFinite(
89
- Math.floor(Number(aiSettings.max_model_failure_recoveries)),
98
+ Math.floor(optionalNumber(
99
+ options.maxModelFailureRecoveries
100
+ ?? aiSettings.max_model_failure_recoveries,
101
+ )),
90
102
  0,
91
103
  MAX_ALLOWED_MODEL_RECOVERIES,
92
104
  DEFAULT_MAX_MODEL_FAILURE_RECOVERIES,
93
105
  );
94
106
 
107
+ let defaultReadOnlyIterations = DEFAULT_MAX_CONSECUTIVE_READ_ONLY_ITERATIONS;
108
+ if (analysisMode === 'direct_answer' || complexity === 'simple') {
109
+ defaultReadOnlyIterations = DEFAULT_SIMPLE_MAX_CONSECUTIVE_READ_ONLY_ITERATIONS;
110
+ } else if (
111
+ analysisMode === 'plan_execute'
112
+ || complexity === 'complex'
113
+ || autonomyLevel === 'high'
114
+ ) {
115
+ // Long-horizon research/implementation needs more productive read/search
116
+ // room before the hard no-progress wrap-up fires.
117
+ defaultReadOnlyIterations = DEFAULT_COMPLEX_MAX_CONSECUTIVE_READ_ONLY_ITERATIONS;
118
+ }
119
+
95
120
  const rawReadOnlyIterations = options.maxConsecutiveReadOnlyIterations != null
96
121
  ? Number(options.maxConsecutiveReadOnlyIterations)
97
- : Number(aiSettings.max_consecutive_read_only_iterations);
122
+ : optionalNumber(aiSettings.max_consecutive_read_only_iterations);
98
123
  const maxConsecutiveReadOnlyIterations = clampFinite(
99
124
  Math.floor(rawReadOnlyIterations),
100
125
  3,
101
126
  MAX_ALLOWED_READ_ONLY_ITERATIONS,
102
- DEFAULT_MAX_CONSECUTIVE_READ_ONLY_ITERATIONS,
127
+ defaultReadOnlyIterations,
103
128
  );
104
129
 
105
130
  // compactionThreshold must be in (0, 1]; clamp to [0.1, 1].
106
131
  const compactionThreshold = clampFinite(
107
- Number(aiSettings.compaction_threshold),
132
+ optionalNumber(options.compactionThreshold ?? aiSettings.compaction_threshold),
108
133
  0.1,
109
134
  1,
110
135
  DEFAULT_COMPACTION_THRESHOLD,
@@ -122,9 +147,9 @@ function buildLoopPolicy(aiSettings = {}, triggerType = 'chat', analysisMode = '
122
147
  // Per-category tool result size budgets (chars)
123
148
  toolResultBudget: {
124
149
  default: defaultBudget,
125
- file: clampFinite(Math.floor(Number(aiSettings.tool_replay_budget_file_chars)), 500, MAX_ALLOWED_BUDGET_CHARS, Math.max(defaultBudget, 6000)),
126
- browser: clampFinite(Math.floor(Number(aiSettings.tool_replay_budget_browser_chars)), 500, MAX_ALLOWED_BUDGET_CHARS, Math.max(defaultBudget, 4000)),
127
- command: clampFinite(Math.floor(Number(aiSettings.tool_replay_budget_command_chars)), 500, MAX_ALLOWED_BUDGET_CHARS, Math.max(defaultBudget, 4000)),
150
+ file: clampFinite(Math.floor(optionalNumber(aiSettings.tool_replay_budget_file_chars)), 500, MAX_ALLOWED_BUDGET_CHARS, Math.max(defaultBudget, 6000)),
151
+ browser: clampFinite(Math.floor(optionalNumber(aiSettings.tool_replay_budget_browser_chars)), 500, MAX_ALLOWED_BUDGET_CHARS, Math.max(defaultBudget, 4000)),
152
+ command: clampFinite(Math.floor(optionalNumber(aiSettings.tool_replay_budget_command_chars)), 500, MAX_ALLOWED_BUDGET_CHARS, Math.max(defaultBudget, 4000)),
128
153
  },
129
154
 
130
155
  // Hard ceiling is always 2× soft, capped at a reasonable absolute max
@@ -152,7 +177,9 @@ function resolveChurnNudgeThreshold(goalContract) {
152
177
  const complexity = String(goalContract?.complexity || 'standard').toLowerCase();
153
178
  const autonomyLevel = String(goalContract?.autonomyLevel || goalContract?.autonomy_level || 'normal').toLowerCase();
154
179
  if (complexity === 'simple') return 2;
155
- if (complexity === 'complex' || autonomyLevel === 'high') return 5;
180
+ // Complex/high-autonomy work, including multi-target research, should get more
181
+ // productive inspection room before the soft churn nudge fires.
182
+ if (complexity === 'complex' || autonomyLevel === 'high') return 6;
156
183
  return 3;
157
184
  }
158
185
 
@@ -43,10 +43,10 @@ function normalizeInterimText(content, platform = null) {
43
43
  function buildBlankMessagingReplyPrompt(attempt, platform = null) {
44
44
  const formattingGuide = buildPlatformFormattingGuide(platform);
45
45
  if (attempt <= 1) {
46
- return `You must send one non-empty reply for the external messaging user right now. Do not call tools. Give either: (a) the concrete outcome, or (b) a clear blocker. If tool work already happened, summarize what you actually tried and where it got blocked. Do not ask the user to repeat the original request. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
46
+ return `You must send one non-empty reply for the external messaging user right now. Do not call tools. Give either: (a) the concrete outcome, or (b) a clear blocker. If tool work already happened, summarize what you actually tried and where it got blocked. Keep the same text-native voice you use with this user: short, direct, human. Do not ask the user to repeat the original request. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
47
47
  }
48
48
 
49
- return `Your previous reply was empty. Return one non-empty message now. Do not call tools. If needed, apologize briefly and explain the blocker in one sentence. Use the run evidence already in the conversation instead of asking the user to restate the task. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
49
+ return `Your previous reply was empty. Return one non-empty message now. Do not call tools. If needed, explain the blocker in one short human sentence. Use the run evidence already in the conversation instead of asking the user to restate the task. Stay text-native and direct. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
50
50
  }
51
51
 
52
52
  function buildProgressUpdatePrompt() {
@@ -68,7 +68,7 @@ function buildProgressUpdatePrompt() {
68
68
 
69
69
  function buildMaxIterationWrapupPrompt(platform = null) {
70
70
  const formattingGuide = buildPlatformFormattingGuide(platform);
71
- return `You have reached the step limit for this run, so this is your final turn. Stop here and do NOT call any tools. Write the single best, most complete answer you can for the user from the work already done in this conversation: lead with the concrete results and what you accomplished, then clearly name anything you could not finish and the specific blocker. Do not output a half-finished thought, a plan for what to do next, or a "let me…" fragment, this message is the final reply. Do not promise future work unless it already happened in this run.\n\n${formattingGuide}`;
71
+ return `You have reached the step limit for this run, so this is your final turn. Stop here and do NOT call any tools. Write the single best, most complete answer you can for the user from the work already done in this conversation: lead with the concrete results and what you accomplished, then clearly name anything you could not finish and the specific blocker. Do not invent entities, products, people, files, outcomes, or tool results that are not already supported by evidence in this conversation. Do not output a half-finished thought, a plan for what to do next, or a "let me…" fragment, this message is the final reply. Do not promise future work unless it already happened in this run.\n\n${formattingGuide}`;
72
72
  }
73
73
 
74
74
  function parseToolExecutionSummary(item) {
@@ -102,8 +102,8 @@ function summarizeRecentWork(toolExecutions = []) {
102
102
  }
103
103
 
104
104
  if (descriptions.length === 0) return '';
105
- if (descriptions.length === 1) return `I ${descriptions[0]}`;
106
- return `I ${descriptions[0]} and ${descriptions[1]}`;
105
+ if (descriptions.length === 1) return descriptions[0];
106
+ return `${descriptions[0]} and ${descriptions[1]}`;
107
107
  }
108
108
 
109
109
  function hasFailureSignal(text) {
@@ -122,7 +122,7 @@ function summarizeUserVisibleBlocker(text) {
122
122
  const normalized = normalizeOutgoingMessage(text);
123
123
  if (!normalized) return '';
124
124
  if (isInternalToolingFailure(normalized)) {
125
- return 'I hit an internal tool issue while checking that';
125
+ return 'hit an internal tool issue while checking that';
126
126
  }
127
127
  return normalized;
128
128
  }
@@ -171,21 +171,21 @@ function buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolE
171
171
  .find(Boolean);
172
172
 
173
173
  if (workSummary && blocker) {
174
- return `${workSummary}, but I got blocked: ${blocker}. I do not have a confirmed finished result yet.`;
174
+ return `${workSummary}, but hit a wall: ${blocker}. no finished result yet.`;
175
175
  }
176
176
  if (blocker) {
177
- return `I got blocked while working on this: ${blocker}. I do not have a confirmed finished result yet.`;
177
+ return `got blocked on this: ${blocker}. no finished result yet.`;
178
178
  }
179
179
  if (workSummary && stepIndex > 0) {
180
- return `${workSummary}, but I do not have a confirmed finished result yet.`;
180
+ return `${workSummary}, but no finished result yet.`;
181
181
  }
182
182
  if (failedStepCount > 0) {
183
- return 'I ran into a tool problem while working on your request, so I do not have a confirmed finished result yet.';
183
+ return 'hit a tool problem while working on this, so no finished result yet.';
184
184
  }
185
185
  if (stepIndex > 0) {
186
- return 'I completed part of the work, but I do not have a confirmed finished result yet.';
186
+ return 'got partway through, but no finished result yet.';
187
187
  }
188
- return 'I could not produce a reliable final reply just now.';
188
+ return 'could not land a reliable final reply just now.';
189
189
  }
190
190
 
191
191
  function buildMessagingFailureScenario({ err, failedStepCount, stepIndex, toolExecutions = [] }) {
@@ -218,11 +218,11 @@ function buildMessagingFailureScenario({ err, failedStepCount, stepIndex, toolEx
218
218
  function buildDeterministicMessagingErrorReply({ err, failedStepCount, stepIndex, toolExecutions = [] }) {
219
219
  const message = normalizeOutgoingMessage(err?.message || '');
220
220
  if (/no ai providers? are currently available/i.test(message)) {
221
- return 'I cannot continue right now because no AI provider is available for this account. Please check the provider settings.';
221
+ return 'can\'t continue right now: no AI provider is available for this account. check provider settings and I can pick it back up.';
222
222
  }
223
223
 
224
224
  if (/(timeout|timed out)/i.test(message)) {
225
- return 'I hit a timeout while processing your request and could not finish it reliably.';
225
+ return 'timed out while working on that, so I could not finish it cleanly.';
226
226
  }
227
227
 
228
228
  const blocker = [...toolExecutions].reverse()
@@ -230,15 +230,15 @@ function buildDeterministicMessagingErrorReply({ err, failedStepCount, stepIndex
230
230
  .map((value) => summarizeUserVisibleBlocker(value))
231
231
  .find(Boolean);
232
232
  if (blocker) {
233
- return `I got blocked while checking this: ${blocker}.`;
233
+ return `got blocked while checking this: ${blocker}.`;
234
234
  }
235
235
 
236
236
  if (isInternalToolingFailure(message)) {
237
- return 'I hit an internal tool issue while checking that, so I do not have a verified answer yet.';
237
+ return 'hit an internal tool issue while checking that, so no verified answer yet.';
238
238
  }
239
239
 
240
240
  if (message) {
241
- return `I got blocked while working on this: ${message}.`;
241
+ return `got blocked while working on this: ${message}.`;
242
242
  }
243
243
 
244
244
  return buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
@@ -0,0 +1,227 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
6
+ const PERMANENT_ERROR_BACKOFF_MS = 30 * 60 * 1000;
7
+ const DISCOVERY_TIMEOUT_MS = 10_000;
8
+ const OLLAMA_REFRESH_INTERVAL_MS = 30_000;
9
+
10
+ const providerModelCache = new Map();
11
+ const providerRefreshes = new Map();
12
+ const ollamaModelCache = new Map();
13
+ const ollamaRefreshes = new Map();
14
+ const openrouterPricingCache = new Map();
15
+
16
+ function inferModelPurpose(id) {
17
+ const value = id.toLowerCase();
18
+ if (/flash|nano|lite|tiny|haiku|scout|mini(?!max)|small/.test(value)) return 'fast';
19
+ if (/r1|qwq|o[0-9]|reasoning|thinking/.test(value)) return 'planning';
20
+ if (/code|coder|starcoder|devstral|codex|codegemma/.test(value)) return 'coding';
21
+ return 'general';
22
+ }
23
+
24
+ const PROVIDER_LABELS = Object.freeze({
25
+ openai: (model) => `${model.id} (OpenAI)`,
26
+ anthropic: (model) => `${model.name || model.id} (Anthropic)`,
27
+ google: (model) => `${model.name || model.id} (Google)`,
28
+ nvidia: (model) => `${model.id} (NVIDIA NIM)`,
29
+ grok: (model) => `${model.id} (xAI)`,
30
+ 'grok-oauth': (model) => `${model.id} (xAI OAuth)`,
31
+ openrouter: (model) => `${model.name || model.id} (OpenRouter)`,
32
+ });
33
+
34
+ function cacheKeyForProvider(providerId, apiKey, baseUrl) {
35
+ return crypto
36
+ .createHash('sha256')
37
+ .update(String(providerId || ''))
38
+ .update('\0')
39
+ .update(String(apiKey || ''))
40
+ .update('\0')
41
+ .update(String(baseUrl || ''))
42
+ .digest('hex');
43
+ }
44
+
45
+ function abortError(signal, fallback = 'Model discovery aborted.') {
46
+ if (signal?.reason instanceof Error) return signal.reason;
47
+ const error = new Error(String(signal?.reason || fallback));
48
+ error.name = 'AbortError';
49
+ error.code = 'ABORT_ERR';
50
+ return error;
51
+ }
52
+
53
+ function waitForSharedResult(promise, signal) {
54
+ if (!signal) return promise;
55
+ if (signal.aborted) return Promise.reject(abortError(signal));
56
+
57
+ return new Promise((resolve, reject) => {
58
+ const onAbort = () => reject(abortError(signal));
59
+ signal.addEventListener('abort', onAbort, { once: true });
60
+ promise.then(resolve, reject).finally(() => {
61
+ signal.removeEventListener('abort', onAbort);
62
+ });
63
+ });
64
+ }
65
+
66
+ async function runDiscovery(factory, timeoutMs = DISCOVERY_TIMEOUT_MS) {
67
+ const controller = new AbortController();
68
+ let timer = null;
69
+ const timeout = new Promise((_, reject) => {
70
+ timer = setTimeout(() => {
71
+ const error = new Error(`Model discovery timed out after ${timeoutMs}ms.`);
72
+ error.code = 'MODEL_DISCOVERY_TIMEOUT';
73
+ controller.abort(error);
74
+ reject(error);
75
+ }, timeoutMs);
76
+ });
77
+
78
+ try {
79
+ return await Promise.race([
80
+ Promise.resolve().then(() => factory(controller.signal)),
81
+ timeout,
82
+ ]);
83
+ } finally {
84
+ clearTimeout(timer);
85
+ }
86
+ }
87
+
88
+ function normalizeRawModels(rawModels, providerId, fallbackIds = []) {
89
+ const source = Array.isArray(rawModels) && rawModels.length > 0
90
+ ? rawModels
91
+ : fallbackIds;
92
+ const labelModel = PROVIDER_LABELS[providerId] || ((model) => model.name || model.id);
93
+ const normalized = [];
94
+ const seen = new Set();
95
+
96
+ for (const raw of source) {
97
+ const model = typeof raw === 'string' ? { id: raw, name: raw } : raw;
98
+ const id = String(model?.id || '').trim();
99
+ if (!id || seen.has(id)) continue;
100
+ seen.add(id);
101
+ normalized.push({
102
+ id,
103
+ label: labelModel({ ...model, id }),
104
+ provider: providerId,
105
+ purpose: inferModelPurpose(id),
106
+ });
107
+ }
108
+ return normalized;
109
+ }
110
+
111
+ function updateOpenRouterPricing(rawModels) {
112
+ if (!Array.isArray(rawModels)) return;
113
+ for (const model of rawModels) {
114
+ if (!model || typeof model !== 'object' || model.pricing?.prompt == null) continue;
115
+ const inputPerM = Number.parseFloat(model.pricing.prompt) * 1_000_000;
116
+ if (!Number.isFinite(inputPerM) || inputPerM < 0) continue;
117
+ openrouterPricingCache.set(model.id, inputPerM);
118
+ if (!model.id.includes('/')) continue;
119
+ const bareId = model.id.slice(model.id.indexOf('/') + 1);
120
+ if (!openrouterPricingCache.has(bareId)) {
121
+ openrouterPricingCache.set(bareId, inputPerM);
122
+ }
123
+ }
124
+ }
125
+
126
+ function isPermanentDiscoveryError(error) {
127
+ return /401|403|unauthorized|forbidden|credits|spending/i.test(String(error?.message || ''));
128
+ }
129
+
130
+ async function loadProviderModels({ providerId, factory, apiKey, baseUrl, existing }) {
131
+ let provider = null;
132
+ try {
133
+ const config = {};
134
+ if (factory.apiKey) config.apiKey = apiKey;
135
+ if (factory.baseUrl) config.baseUrl = baseUrl;
136
+ provider = new factory.Provider(config);
137
+ const rawModels = await runDiscovery((signal) => provider.listModels(signal));
138
+ if (providerId === 'openrouter') updateOpenRouterPricing(rawModels);
139
+ const models = normalizeRawModels(rawModels, providerId, provider.models);
140
+ const entry = { models, expiresAt: Date.now() + REFRESH_INTERVAL_MS };
141
+ return entry;
142
+ } catch (error) {
143
+ console.warn(`[Models] Failed to refresh ${providerId} models:`, error.message);
144
+ const models = existing?.models?.length
145
+ ? existing.models
146
+ : normalizeRawModels([], providerId, provider?.models || []);
147
+ const retryAfterMs = isPermanentDiscoveryError(error)
148
+ ? PERMANENT_ERROR_BACKOFF_MS
149
+ : REFRESH_INTERVAL_MS;
150
+ return { models, expiresAt: Date.now() + retryAfterMs };
151
+ }
152
+ }
153
+
154
+ async function refreshProviderModelList({ providerId, factory, apiKey, baseUrl, signal }) {
155
+ const cacheKey = cacheKeyForProvider(providerId, apiKey, baseUrl);
156
+ const existing = providerModelCache.get(cacheKey);
157
+ if (existing && existing.expiresAt > Date.now()) return existing.models;
158
+
159
+ let refresh = providerRefreshes.get(cacheKey);
160
+ if (!refresh) {
161
+ refresh = loadProviderModels({ providerId, factory, apiKey, baseUrl, existing })
162
+ .then((entry) => {
163
+ providerModelCache.set(cacheKey, entry);
164
+ return entry.models;
165
+ })
166
+ .finally(() => providerRefreshes.delete(cacheKey));
167
+ providerRefreshes.set(cacheKey, refresh);
168
+ }
169
+ return waitForSharedResult(refresh, signal);
170
+ }
171
+
172
+ async function loadOllamaModels({ baseUrl, Provider, existing }) {
173
+ try {
174
+ const provider = new Provider({ baseUrl });
175
+ const rawModels = await runDiscovery((signal) => provider.listModels(signal));
176
+ const models = normalizeRawModels(rawModels, 'ollama').map((model) => ({
177
+ ...model,
178
+ label: `${model.id} (Ollama / Local)`,
179
+ purpose: 'general',
180
+ }));
181
+ return { models, expiresAt: Date.now() + OLLAMA_REFRESH_INTERVAL_MS };
182
+ } catch (error) {
183
+ console.warn('[Models] Failed to refresh Ollama models:', error.message);
184
+ return {
185
+ models: existing?.models || [],
186
+ expiresAt: Date.now() + OLLAMA_REFRESH_INTERVAL_MS,
187
+ };
188
+ }
189
+ }
190
+
191
+ async function refreshOllamaModels({ baseUrl, Provider, signal }) {
192
+ const cacheKey = String(baseUrl || '');
193
+ const existing = ollamaModelCache.get(cacheKey);
194
+ if (existing && existing.expiresAt > Date.now()) return existing.models;
195
+
196
+ let refresh = ollamaRefreshes.get(cacheKey);
197
+ if (!refresh) {
198
+ refresh = loadOllamaModels({ baseUrl, Provider, existing })
199
+ .then((entry) => {
200
+ ollamaModelCache.set(cacheKey, entry);
201
+ return entry.models;
202
+ })
203
+ .finally(() => ollamaRefreshes.delete(cacheKey));
204
+ ollamaRefreshes.set(cacheKey, refresh);
205
+ }
206
+ return waitForSharedResult(refresh, signal);
207
+ }
208
+
209
+ function getInputCostPerM(modelId) {
210
+ return openrouterPricingCache.get(modelId);
211
+ }
212
+
213
+ function classifyPriceTier(modelId) {
214
+ const costPerM = getInputCostPerM(modelId);
215
+ if (costPerM === undefined) return null;
216
+ if (costPerM === 0) return 'free';
217
+ if (costPerM < 0.5) return 'cheap';
218
+ if (costPerM < 5) return 'medium';
219
+ return 'expensive';
220
+ }
221
+
222
+ module.exports = {
223
+ classifyPriceTier,
224
+ getInputCostPerM,
225
+ refreshOllamaModels,
226
+ refreshProviderModelList,
227
+ };
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const { isAbortError } = require('../../utils/abort');
4
+ const {
5
+ getErrorCode,
6
+ getHttpStatus,
7
+ isTransientIoError,
8
+ retryAfterMilliseconds,
9
+ } = require('../../utils/retry');
10
+
11
+ const DEFAULT_COOLDOWN_MS = 15 * 60 * 1000;
12
+ const DEFAULT_RECOVERY_COOLDOWN_MS = 60 * 1000;
13
+ const failures = new Map();
14
+
15
+ function cacheKey(userId, agentId, modelSelectionId) {
16
+ return [
17
+ String(userId ?? ''),
18
+ String(agentId ?? 'main'),
19
+ String(modelSelectionId || '').trim(),
20
+ ].join(':');
21
+ }
22
+
23
+ function readPermanentCooldownMs() {
24
+ const configured = Number(process.env.NEOAGENT_MODEL_NOT_FOUND_COOLDOWN_MS);
25
+ if (!Number.isFinite(configured)) return DEFAULT_COOLDOWN_MS;
26
+ return Math.max(1_000, Math.min(configured, 24 * 60 * 60 * 1000));
27
+ }
28
+
29
+ function readRecoveryCooldownMs() {
30
+ const configured = Number(process.env.NEOAGENT_MODEL_RECOVERY_COOLDOWN_MS);
31
+ if (!Number.isFinite(configured)) return DEFAULT_RECOVERY_COOLDOWN_MS;
32
+ return Math.max(1_000, Math.min(configured, 15 * 60 * 1000));
33
+ }
34
+
35
+ function isPermanentModelFailure(error) {
36
+ const status = getHttpStatus(error);
37
+ if (status === 404) return true;
38
+ return /\b404\b.*\b(model|nim|provider|request)\b|\b(model|nim)\b.*\b404\b/i
39
+ .test(String(error?.message || ''));
40
+ }
41
+
42
+ function isRecoverableModelFailure(error) {
43
+ if (!error || isAbortError(error) || isPermanentModelFailure(error)) return false;
44
+ const status = getHttpStatus(error);
45
+ if (status === 401 || status === 403 || status === 429 || (status >= 500 && status < 600)) {
46
+ return true;
47
+ }
48
+ const code = String(getErrorCode(error) || '');
49
+ if (code === 'MODEL_EMPTY_RESPONSE' || code === 'MODEL_CALL_TIMEOUT') return true;
50
+ if (isTransientIoError(error)) return true;
51
+ return /\bempty response|temporarily unavailable|overloaded|rate.?limit|timed? ?out|network error\b/i
52
+ .test(String(error?.message || ''));
53
+ }
54
+
55
+ function resolveCooldownMs(error, now) {
56
+ if (isPermanentModelFailure(error)) return readPermanentCooldownMs();
57
+ if (!isRecoverableModelFailure(error)) return 0;
58
+
59
+ const configured = readRecoveryCooldownMs();
60
+ const retryAfter = retryAfterMilliseconds(
61
+ error?.headers || error?.response?.headers,
62
+ now,
63
+ );
64
+ if (!Number.isFinite(retryAfter)) return configured;
65
+ return Math.max(configured, Math.min(retryAfter, 15 * 60 * 1000));
66
+ }
67
+
68
+ function recordModelFailure(userId, agentId, modelSelectionId, error, now = Date.now()) {
69
+ const modelId = String(modelSelectionId || '').trim();
70
+ const cooldownMs = resolveCooldownMs(error, now);
71
+ if (!modelId || cooldownMs <= 0) return false;
72
+ failures.set(cacheKey(userId, agentId, modelId), {
73
+ expiresAt: now + cooldownMs,
74
+ });
75
+ return true;
76
+ }
77
+
78
+ function recordModelSuccess(userId, agentId, modelSelectionId) {
79
+ const modelId = String(modelSelectionId || '').trim();
80
+ if (!modelId) return false;
81
+ return failures.delete(cacheKey(userId, agentId, modelId));
82
+ }
83
+
84
+ function isModelCoolingDown(userId, agentId, modelSelectionId, now = Date.now()) {
85
+ const modelId = String(modelSelectionId || '').trim();
86
+ if (!modelId) return false;
87
+ const key = cacheKey(userId, agentId, modelId);
88
+ const entry = failures.get(key);
89
+ if (!entry) return false;
90
+ if (entry.expiresAt <= now) {
91
+ failures.delete(key);
92
+ return false;
93
+ }
94
+ return true;
95
+ }
96
+
97
+ function clearModelFailureCache() {
98
+ failures.clear();
99
+ }
100
+
101
+ module.exports = {
102
+ clearModelFailureCache,
103
+ isModelCoolingDown,
104
+ isPermanentModelFailure,
105
+ isRecoverableModelFailure,
106
+ recordModelFailure,
107
+ recordModelSuccess,
108
+ };