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.
- package/docs/agent-run-lifecycle.md +10 -0
- package/extensions/chrome-browser/background.mjs +318 -88
- package/extensions/chrome-browser/http.mjs +136 -0
- package/extensions/chrome-browser/protocol.mjs +654 -90
- package/flutter_app/lib/main_chat.dart +118 -739
- package/flutter_app/lib/main_controller.dart +111 -20
- package/flutter_app/lib/main_integrations.dart +607 -8
- package/flutter_app/lib/main_models.dart +3 -0
- package/flutter_app/lib/main_operations.dart +334 -321
- package/flutter_app/lib/main_security.dart +266 -112
- package/flutter_app/lib/main_settings.dart +4 -3
- package/flutter_app/lib/main_shared.dart +14 -11
- package/flutter_app/lib/src/backend_client.dart +78 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
- package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
- package/flutter_app/windows/runner/flutter_window.cpp +143 -32
- package/landing/index.html +3 -1
- package/lib/manager.js +106 -89
- package/lib/schema_migrations.js +145 -13
- package/package.json +30 -15
- package/runtime/paths.js +49 -5
- package/server/db/database.js +4 -4
- package/server/guest-agent.cli.package.json +13 -0
- package/server/guest_agent.js +85 -40
- package/server/http/middleware.js +24 -0
- package/server/http/routes.js +11 -6
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +73083 -72209
- package/server/routes/admin.js +1 -1
- package/server/routes/agents.js +35 -2
- package/server/routes/android.js +30 -34
- package/server/routes/browser.js +23 -15
- package/server/routes/desktop.js +18 -1
- package/server/routes/integrations.js +107 -1
- package/server/routes/memory.js +1 -0
- package/server/routes/settings.js +16 -5
- package/server/routes/social_reach.js +12 -3
- package/server/routes/social_video.js +4 -0
- package/server/services/agents/manager.js +1 -1
- package/server/services/ai/capabilityHealth.js +62 -96
- package/server/services/ai/compaction.js +7 -2
- package/server/services/ai/history.js +45 -6
- package/server/services/ai/integrated_tools/http_request.js +8 -0
- package/server/services/ai/loop/agent_engine_core.js +496 -166
- package/server/services/ai/loop/blank_recovery.js +5 -4
- package/server/services/ai/loop/callbacks.js +1 -0
- package/server/services/ai/loop/completion_judge.js +121 -5
- package/server/services/ai/loop/conversation_loop.js +620 -340
- package/server/services/ai/loop/lifecycle.js +108 -0
- package/server/services/ai/loop/messaging_delivery.js +129 -57
- package/server/services/ai/loop/model_call_guard.js +91 -0
- package/server/services/ai/loop/model_io.js +48 -56
- package/server/services/ai/loop/progress_classification.js +2 -0
- package/server/services/ai/loop/tool_dispatch.js +28 -8
- package/server/services/ai/loopPolicy.js +48 -21
- package/server/services/ai/messagingFallback.js +17 -17
- package/server/services/ai/model_discovery.js +227 -0
- package/server/services/ai/model_failure_cache.js +108 -0
- package/server/services/ai/model_identity.js +71 -0
- package/server/services/ai/models.js +68 -163
- package/server/services/ai/providerRetry.js +17 -59
- package/server/services/ai/provider_selector.js +166 -0
- package/server/services/ai/providers/anthropic.js +4 -4
- package/server/services/ai/providers/claudeCode.js +21 -33
- package/server/services/ai/providers/githubCopilot.js +41 -20
- package/server/services/ai/providers/google.js +135 -97
- package/server/services/ai/providers/grok.js +6 -5
- package/server/services/ai/providers/grokOauth.js +19 -27
- package/server/services/ai/providers/nvidia.js +12 -7
- package/server/services/ai/providers/ollama.js +114 -86
- package/server/services/ai/providers/ollama_stream.js +142 -0
- package/server/services/ai/providers/openai.js +41 -7
- package/server/services/ai/providers/openaiCodex.js +13 -4
- package/server/services/ai/providers/openrouter.js +31 -9
- package/server/services/ai/providers/provider_error.js +36 -0
- package/server/services/ai/settings.js +26 -2
- package/server/services/ai/systemPrompt.js +19 -12
- package/server/services/ai/taskAnalysis.js +58 -10
- package/server/services/ai/terminal_reply.js +18 -0
- package/server/services/ai/toolEvidence.js +350 -29
- package/server/services/ai/tools.js +190 -111
- package/server/services/android/controller.js +770 -237
- package/server/services/android/process.js +140 -0
- package/server/services/android/sdk_download.js +143 -0
- package/server/services/android/uia.js +6 -5
- package/server/services/artifacts/store.js +24 -0
- package/server/services/browser/controller.js +843 -385
- package/server/services/browser/extension/gateway.js +40 -16
- package/server/services/browser/extension/protocol.js +15 -1
- package/server/services/browser/extension/provider.js +71 -47
- package/server/services/browser/extension/registry.js +155 -34
- package/server/services/cli/executor.js +62 -9
- package/server/services/credentials/bitwarden_cli.js +322 -0
- package/server/services/credentials/broker.js +594 -0
- package/server/services/desktop/gateway.js +41 -4
- package/server/services/desktop/protocol.js +3 -0
- package/server/services/desktop/provider.js +39 -42
- package/server/services/desktop/registry.js +137 -52
- package/server/services/integrations/bitwarden/constants.js +14 -0
- package/server/services/integrations/bitwarden/provider.js +197 -0
- package/server/services/integrations/bitwarden/snapshot.js +65 -0
- package/server/services/integrations/figma/provider.js +78 -12
- package/server/services/integrations/github/common.js +11 -6
- package/server/services/integrations/github/provider.js +52 -53
- package/server/services/integrations/google/provider.js +55 -19
- package/server/services/integrations/home_assistant/network.js +17 -20
- package/server/services/integrations/home_assistant/provider.js +7 -5
- package/server/services/integrations/home_assistant/tools.js +17 -5
- package/server/services/integrations/http.js +51 -0
- package/server/services/integrations/manager.js +159 -53
- package/server/services/integrations/microsoft/provider.js +80 -13
- package/server/services/integrations/neoarchive/provider.js +55 -29
- package/server/services/integrations/neorecall/client.js +17 -10
- package/server/services/integrations/neorecall/provider.js +20 -11
- package/server/services/integrations/notion/provider.js +16 -13
- package/server/services/integrations/oauth_provider.js +115 -51
- package/server/services/integrations/registry.js +2 -0
- package/server/services/integrations/slack/provider.js +98 -9
- package/server/services/integrations/spotify/provider.js +67 -71
- package/server/services/integrations/trello/provider.js +21 -7
- package/server/services/integrations/weather/provider.js +18 -12
- package/server/services/integrations/whatsapp/provider.js +76 -16
- package/server/services/manager.js +110 -1
- package/server/services/memory/embedding_index.js +20 -8
- package/server/services/memory/embeddings.js +151 -90
- package/server/services/memory/ingestion.js +50 -9
- package/server/services/memory/ingestion_documents.js +13 -3
- package/server/services/memory/manager.js +52 -19
- package/server/services/messaging/automation.js +85 -10
- package/server/services/messaging/formatting_guides.js +7 -4
- package/server/services/messaging/http_platforms.js +33 -13
- package/server/services/messaging/inbound_queue.js +78 -24
- package/server/services/messaging/inbound_store.js +224 -0
- package/server/services/messaging/manager.js +326 -51
- package/server/services/messaging/typing_keepalive.js +5 -2
- package/server/services/messaging/whatsapp.js +22 -14
- package/server/services/network/http.js +210 -0
- package/server/services/network/safe_request.js +307 -0
- package/server/services/runtime/backends/local-vm.js +227 -67
- package/server/services/runtime/docker-vm-manager.js +9 -0
- package/server/services/runtime/guest_bootstrap.js +30 -4
- package/server/services/runtime/guest_image.js +43 -12
- package/server/services/runtime/manager.js +77 -23
- package/server/services/runtime/validation.js +7 -6
- package/server/services/security/tool_categories.js +6 -0
- package/server/services/social_reach/channels/github.js +10 -4
- package/server/services/social_reach/channels/reddit.js +4 -4
- package/server/services/social_reach/channels/rss.js +2 -2
- package/server/services/social_reach/channels/social_video.js +12 -7
- package/server/services/social_reach/channels/v2ex.js +21 -8
- package/server/services/social_reach/channels/x.js +2 -2
- package/server/services/social_reach/channels/xueqiu.js +5 -5
- package/server/services/social_reach/service.js +9 -6
- package/server/services/social_reach/utils.js +65 -14
- package/server/services/social_video/service.js +160 -50
- package/server/services/tasks/integration_runtime.js +18 -8
- package/server/services/tasks/runtime.js +39 -4
- package/server/services/voice/agentBridge.js +17 -4
- package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
- package/server/services/voice/liveSession.js +31 -0
- package/server/services/voice/message.js +1 -1
- package/server/services/voice/openaiSpeech.js +33 -8
- package/server/services/voice/providers.js +233 -151
- package/server/services/voice/runtime.js +2 -2
- package/server/services/voice/runtimeManager.js +118 -20
- package/server/services/voice/turnRunner.js +6 -0
- package/server/services/wearable/firmware_manifest.js +51 -13
- package/server/services/wearable/service.js +1 -0
- package/server/utils/abort.js +96 -0
- package/server/utils/cloud-security.js +110 -3
- package/server/utils/files.js +31 -0
- package/server/utils/image_payload.js +95 -0
- 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 {
|
|
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
|
-
|
|
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 →
|
|
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
|
|
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
|
|
17
|
-
//
|
|
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
|
|
29
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
500,
|
|
76
|
-
|
|
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(
|
|
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(
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
126
|
-
browser: clampFinite(Math.floor(
|
|
127
|
-
command: clampFinite(Math.floor(
|
|
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
|
-
|
|
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,
|
|
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
|
|
106
|
-
return
|
|
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 '
|
|
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
|
|
174
|
+
return `${workSummary}, but hit a wall: ${blocker}. no finished result yet.`;
|
|
175
175
|
}
|
|
176
176
|
if (blocker) {
|
|
177
|
-
return `
|
|
177
|
+
return `got blocked on this: ${blocker}. no finished result yet.`;
|
|
178
178
|
}
|
|
179
179
|
if (workSummary && stepIndex > 0) {
|
|
180
|
-
return `${workSummary}, but
|
|
180
|
+
return `${workSummary}, but no finished result yet.`;
|
|
181
181
|
}
|
|
182
182
|
if (failedStepCount > 0) {
|
|
183
|
-
return '
|
|
183
|
+
return 'hit a tool problem while working on this, so no finished result yet.';
|
|
184
184
|
}
|
|
185
185
|
if (stepIndex > 0) {
|
|
186
|
-
return '
|
|
186
|
+
return 'got partway through, but no finished result yet.';
|
|
187
187
|
}
|
|
188
|
-
return '
|
|
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 '
|
|
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 '
|
|
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 `
|
|
233
|
+
return `got blocked while checking this: ${blocker}.`;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
if (isInternalToolingFailure(message)) {
|
|
237
|
-
return '
|
|
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 `
|
|
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
|
+
};
|