neoagent 2.5.2-beta.8 → 3.0.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.
- package/README.md +5 -1
- package/docs/integrations.md +11 -7
- package/package.json +2 -2
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/services/ai/deliverables/artifact_helpers.js +108 -16
- package/server/services/ai/deliverables/selector.js +17 -0
- package/server/services/ai/engine.js +2 -4724
- package/server/services/ai/loop/agent_engine_core.js +1590 -0
- package/server/services/ai/loop/blank_recovery.js +37 -0
- package/server/services/ai/loop/callbacks.js +151 -0
- package/server/services/ai/loop/completion_judge.js +252 -0
- package/server/services/ai/loop/conversation_loop.js +2447 -0
- package/server/services/ai/loop/delivery_state.js +27 -0
- package/server/services/ai/loop/error_recovery.js +38 -0
- package/server/services/ai/loop/iteration_budget.js +24 -0
- package/server/services/ai/loop/messaging_delivery.js +296 -0
- package/server/services/ai/loop/model_io.js +258 -0
- package/server/services/ai/loop/progress_classification.js +178 -0
- package/server/services/ai/loop/progress_monitor.js +81 -0
- package/server/services/ai/loop/run_state.js +356 -0
- package/server/services/ai/loop/tool_dispatch.js +231 -0
- package/server/services/ai/loopPolicy.js +15 -6
- package/server/services/ai/messagingFallback.js +23 -1
- package/server/services/ai/preModelCompaction.js +31 -0
- package/server/services/ai/repetitionGuard.js +47 -2
- package/server/services/ai/systemPrompt.js +6 -0
- package/server/services/ai/taskAnalysis.js +10 -0
- package/server/services/ai/toolEvidence.js +15 -3
- package/server/services/ai/toolResult.js +29 -0
- package/server/services/ai/toolSelector.js +20 -3
- package/server/services/ai/tools.js +196 -26
- package/server/services/integrations/github/common.js +2 -2
- package/server/services/integrations/github/repos.js +123 -55
- package/server/services/runtime/docker-vm-manager.js +21 -1
- package/server/services/workspace/manager.js +56 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function createDeliveryState(seed = {}) {
|
|
4
|
+
return {
|
|
5
|
+
alreadySent: seed.alreadySent === true,
|
|
6
|
+
finalResponseSent: seed.finalResponseSent === true,
|
|
7
|
+
finalContentDelivered: seed.finalContentDelivered === true,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function markInterimDelivered(state) {
|
|
12
|
+
if (!state) return;
|
|
13
|
+
state.alreadySent = true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function markFinalDelivered(state) {
|
|
17
|
+
if (!state) return;
|
|
18
|
+
state.alreadySent = true;
|
|
19
|
+
state.finalResponseSent = true;
|
|
20
|
+
state.finalContentDelivered = true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
createDeliveryState,
|
|
25
|
+
markInterimDelivered,
|
|
26
|
+
markFinalDelivered,
|
|
27
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function hasTerminalMessagingDelivery(runMeta = null) {
|
|
4
|
+
return runMeta?.finalDeliverySent === true
|
|
5
|
+
|| runMeta?.finalResponseSent === true
|
|
6
|
+
|| runMeta?.finalContentDelivered === true
|
|
7
|
+
|| runMeta?.deliveryState?.finalResponseSent === true
|
|
8
|
+
|| runMeta?.deliveryState?.finalContentDelivered === true
|
|
9
|
+
|| runMeta?.noResponse === true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isRateLimitError(error = null) {
|
|
13
|
+
return /429|rate.?limit|free-models-per/i.test(String(error?.message || ''));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function shouldRetryMessagingRun() {
|
|
17
|
+
// Messaging recovery must stay inside the current run. Re-entering
|
|
18
|
+
// runWithModel starts over from the original task and repeats tool work.
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function shouldSendMessagingErrorFallback({
|
|
23
|
+
triggerSource,
|
|
24
|
+
options = {},
|
|
25
|
+
runMeta = null,
|
|
26
|
+
} = {}) {
|
|
27
|
+
return triggerSource === 'messaging'
|
|
28
|
+
&& Boolean(options.source)
|
|
29
|
+
&& Boolean(options.chatId)
|
|
30
|
+
&& !hasTerminalMessagingDelivery(runMeta);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
hasTerminalMessagingDelivery,
|
|
35
|
+
isRateLimitError,
|
|
36
|
+
shouldRetryMessagingRun,
|
|
37
|
+
shouldSendMessagingErrorFallback,
|
|
38
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class IterationBudget {
|
|
4
|
+
constructor(maxTotal) {
|
|
5
|
+
this.maxTotal = Math.max(0, Number(maxTotal) || 0);
|
|
6
|
+
this.used = 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
consume() {
|
|
10
|
+
if (this.used >= this.maxTotal) return false;
|
|
11
|
+
this.used += 1;
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
refund() {
|
|
16
|
+
if (this.used > 0) this.used -= 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get remaining() {
|
|
20
|
+
return Math.max(0, this.maxTotal - this.used);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { IterationBudget };
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
splitOutgoingMessageForPlatform,
|
|
5
|
+
} = require('../../messaging/formatting_guides');
|
|
6
|
+
const {
|
|
7
|
+
normalizeOutgoingMessage,
|
|
8
|
+
} = require('../messagingFallback');
|
|
9
|
+
const { withProviderRetry, isTransientError } = require('../providerRetry');
|
|
10
|
+
const { shortenRunId, summarizeForLog } = require('../logFormat');
|
|
11
|
+
const {
|
|
12
|
+
TICK_MS,
|
|
13
|
+
REPEAT_UPDATE_MS,
|
|
14
|
+
buildInitialProgressLedger,
|
|
15
|
+
buildProgressNudge,
|
|
16
|
+
evaluateProgressLiveness,
|
|
17
|
+
} = require('./progress_monitor');
|
|
18
|
+
|
|
19
|
+
function isoNow() {
|
|
20
|
+
return new Date().toISOString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function timestampMs(value, fallback = 0) {
|
|
24
|
+
const resolved = value ? Date.parse(value) : NaN;
|
|
25
|
+
return Number.isFinite(resolved) ? resolved : fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requireSuccessfulMessagingDelivery(result, label = 'Messaging delivery') {
|
|
29
|
+
if (result?.success === true && result?.suppressed !== true) {
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
const reason = String(
|
|
33
|
+
result?.error
|
|
34
|
+
|| result?.reason
|
|
35
|
+
|| result?.result?.error
|
|
36
|
+
|| result?.result?.reason
|
|
37
|
+
|| 'the platform did not confirm delivery',
|
|
38
|
+
).trim();
|
|
39
|
+
const error = new Error(`${label} failed: ${reason}`);
|
|
40
|
+
error.code = 'MESSAGING_DELIVERY_FAILED';
|
|
41
|
+
error.deliveryResult = result || null;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Harness-driven progress: the originating chat must see autonomous updates during
|
|
46
|
+
// long runs even when a weak model never calls send_interim_update itself. The
|
|
47
|
+
// supervisor paces this call (FIRST/REPEAT thresholds + a repeat guard). The update
|
|
48
|
+
// text is always authored by the run's own agent loop (composeProgressUpdate); we
|
|
49
|
+
// never emit a hard-coded status line. If the loop can't compose one this tick, we
|
|
50
|
+
// skip the visible send and only nudge the model, then try again next tick.
|
|
51
|
+
async function sendRuntimeMessagingHeartbeat(engine, runId, options = {}) {
|
|
52
|
+
const runMeta = engine.getRunMeta(runId);
|
|
53
|
+
if (!runMeta || runMeta.aborted) return { sent: false, skipped: true };
|
|
54
|
+
if (runMeta.triggerSource !== 'messaging') {
|
|
55
|
+
return { sent: false, skipped: true };
|
|
56
|
+
}
|
|
57
|
+
const createdAt = isoNow();
|
|
58
|
+
const heartbeatCount = Number(runMeta.progressLedger?.heartbeatCount || 0) + 1;
|
|
59
|
+
runMeta.lastSupervisorNudgeAt = createdAt;
|
|
60
|
+
engine.updateRunProgress(runId, { heartbeatCount });
|
|
61
|
+
|
|
62
|
+
const ledger = runMeta.progressLedger || {};
|
|
63
|
+
const { platform, chatId } = runMeta.messagingContext || {};
|
|
64
|
+
|
|
65
|
+
if (engine.messagingManager && !runMeta.terminalInterim && platform && chatId
|
|
66
|
+
&& typeof runMeta.composeProgressUpdate === 'function') {
|
|
67
|
+
let statusMsg = '';
|
|
68
|
+
try {
|
|
69
|
+
const composed = await runMeta.composeProgressUpdate({ stalled: options.stalled === true });
|
|
70
|
+
if (normalizeOutgoingMessage(composed, platform)) statusMsg = String(composed).trim();
|
|
71
|
+
} catch { /* no dynamic update available this tick */ }
|
|
72
|
+
if (statusMsg) try {
|
|
73
|
+
const delivery = await engine.messagingManager.sendMessage(runMeta.userId, platform, chatId, statusMsg, {
|
|
74
|
+
runId,
|
|
75
|
+
agentId: runMeta.agentId,
|
|
76
|
+
});
|
|
77
|
+
if (delivery?.success === true && delivery?.suppressed !== true) {
|
|
78
|
+
const nowIso = isoNow();
|
|
79
|
+
runMeta.progressLedger = { ...ledger, lastUserVisibleUpdateAt: nowIso };
|
|
80
|
+
runMeta.lastInterimMessage = statusMsg;
|
|
81
|
+
engine.updateRunProgress(runId, { lastUserVisibleUpdateAt: nowIso });
|
|
82
|
+
engine.recordRunEvent(runMeta.userId, runId, 'progress_heartbeat_sent', {
|
|
83
|
+
stalled: options.stalled === true,
|
|
84
|
+
currentTool: ledger.currentTool || null,
|
|
85
|
+
currentStep: ledger.currentStep || null,
|
|
86
|
+
phase: ledger.currentPhase || 'idle',
|
|
87
|
+
userVisible: true,
|
|
88
|
+
createdAt,
|
|
89
|
+
}, { agentId: runMeta.agentId });
|
|
90
|
+
// Still nudge the model so its next turn can deliver a richer, real update.
|
|
91
|
+
engine.enqueueSystemSteering(
|
|
92
|
+
runId,
|
|
93
|
+
buildProgressNudge({ stalled: options.stalled === true }),
|
|
94
|
+
{ reason: options.stalled === true ? 'stalled_progress_check' : 'progress_check' },
|
|
95
|
+
);
|
|
96
|
+
return { sent: true, heartbeat: true };
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.warn('[Engine] Progress heartbeat send failed:', err?.message || err);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
engine.recordRunEvent(runMeta.userId, runId, 'progress_heartbeat_sent', {
|
|
104
|
+
stalled: options.stalled === true,
|
|
105
|
+
currentTool: ledger.currentTool || null,
|
|
106
|
+
currentStep: ledger.currentStep || null,
|
|
107
|
+
phase: ledger.currentPhase || 'idle',
|
|
108
|
+
userVisible: false,
|
|
109
|
+
createdAt,
|
|
110
|
+
}, { agentId: runMeta.agentId });
|
|
111
|
+
engine.enqueueSystemSteering(
|
|
112
|
+
runId,
|
|
113
|
+
buildProgressNudge({ stalled: options.stalled === true }),
|
|
114
|
+
{ reason: options.stalled === true ? 'stalled_progress_check' : 'progress_check' },
|
|
115
|
+
);
|
|
116
|
+
return { sent: false, heartbeat: true, queued: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function shouldSendMessagingFinalFallback(_engine, runMeta, content, platform = null) {
|
|
120
|
+
const cleanedContent = normalizeOutgoingMessage(content || '', platform, {
|
|
121
|
+
collapseWhitespace: false,
|
|
122
|
+
});
|
|
123
|
+
const lastFinalDeliveryMessage = normalizeOutgoingMessage(
|
|
124
|
+
runMeta?.lastSentMessage
|
|
125
|
+
|| (Array.isArray(runMeta?.sentMessages) ? runMeta.sentMessages[runMeta.sentMessages.length - 1] : '')
|
|
126
|
+
|| '',
|
|
127
|
+
platform,
|
|
128
|
+
);
|
|
129
|
+
return Boolean(
|
|
130
|
+
cleanedContent
|
|
131
|
+
&& !runMeta?.terminalInterim
|
|
132
|
+
&& runMeta?.explicitMessageSent !== true
|
|
133
|
+
&& runMeta?.finalDeliverySent !== true
|
|
134
|
+
&& runMeta?.deliveryState?.finalContentDelivered !== true
|
|
135
|
+
&& !lastFinalDeliveryMessage
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function deliverMessagingFinalFallback(engine, {
|
|
140
|
+
runId,
|
|
141
|
+
userId,
|
|
142
|
+
agentId,
|
|
143
|
+
platform,
|
|
144
|
+
chatId,
|
|
145
|
+
content,
|
|
146
|
+
}) {
|
|
147
|
+
const runMeta = engine.getRunMeta(runId);
|
|
148
|
+
if (!runMeta || !engine.messagingManager) return { sent: false, skipped: true };
|
|
149
|
+
const cleanedContent = normalizeOutgoingMessage(content || '', platform, {
|
|
150
|
+
collapseWhitespace: false,
|
|
151
|
+
});
|
|
152
|
+
if (!shouldSendMessagingFinalFallback(engine, runMeta, cleanedContent, platform)) {
|
|
153
|
+
return { sent: false, skipped: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const chunks = splitOutgoingMessageForPlatform(platform, cleanedContent);
|
|
157
|
+
console.info(
|
|
158
|
+
`[Run ${shortenRunId(runId)}] messaging_fallback chunks=${chunks.length} to=${summarizeForLog(chatId, 80)}`
|
|
159
|
+
);
|
|
160
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
161
|
+
if (i > 0) {
|
|
162
|
+
const delay = Math.max(1000, Math.min(chunks[i].length * 30, 4000));
|
|
163
|
+
await engine.messagingManager.sendTyping(userId, platform, chatId, true, { agentId }).catch(() => {});
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
await withProviderRetry(async () => {
|
|
168
|
+
const deliveryResult = await engine.messagingManager.sendMessage(
|
|
169
|
+
userId,
|
|
170
|
+
platform,
|
|
171
|
+
chatId,
|
|
172
|
+
chunks[i],
|
|
173
|
+
{ runId, agentId },
|
|
174
|
+
);
|
|
175
|
+
return requireSuccessfulMessagingDelivery(deliveryResult, 'Final messaging delivery');
|
|
176
|
+
}, {
|
|
177
|
+
...engine.messagingDeliveryRetry,
|
|
178
|
+
label: `MessagingDelivery ${platform}`,
|
|
179
|
+
isRetryable: (error) => (
|
|
180
|
+
error?.retryable !== false
|
|
181
|
+
&& (
|
|
182
|
+
error?.code === 'MESSAGING_DELIVERY_FAILED'
|
|
183
|
+
|| isTransientError(error)
|
|
184
|
+
)
|
|
185
|
+
),
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
error.disableAutonomousRetry = true;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
runMeta.lastSentMessage = chunks[chunks.length - 1] || cleanedContent;
|
|
194
|
+
runMeta.sentMessages = Array.isArray(runMeta.sentMessages)
|
|
195
|
+
? [...runMeta.sentMessages, ...chunks]
|
|
196
|
+
: chunks.slice();
|
|
197
|
+
engine.markRunFinalDelivery(runId, runMeta.lastSentMessage);
|
|
198
|
+
return { sent: true, chunks };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function tickMessagingProgressSupervisor(engine, runId) {
|
|
202
|
+
const runMeta = engine.getRunMeta(runId);
|
|
203
|
+
if (!runMeta || runMeta.aborted || runMeta.triggerSource !== 'messaging') {
|
|
204
|
+
return { sent: false, skipped: true };
|
|
205
|
+
}
|
|
206
|
+
if (runMeta.terminalInterim) {
|
|
207
|
+
return { sent: false, skipped: true };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const ledger = runMeta.progressLedger || buildInitialProgressLedger({
|
|
211
|
+
startedAt: runMeta.startedAtIso || isoNow(),
|
|
212
|
+
});
|
|
213
|
+
runMeta.progressLedger = ledger;
|
|
214
|
+
const liveness = evaluateProgressLiveness(runMeta);
|
|
215
|
+
const stalled = liveness.stalled;
|
|
216
|
+
|
|
217
|
+
if (stalled && !ledger.stallNotifiedAt) {
|
|
218
|
+
engine.updateRunProgress(runId, {
|
|
219
|
+
stallNotifiedAt: isoNow(),
|
|
220
|
+
progressState: 'stalled',
|
|
221
|
+
});
|
|
222
|
+
engine.recordRunEvent(runMeta.userId, runId, 'progress_stalled', {
|
|
223
|
+
currentTool: ledger.currentTool || null,
|
|
224
|
+
currentStep: ledger.currentStep || null,
|
|
225
|
+
phase: ledger.currentPhase || 'idle',
|
|
226
|
+
}, { agentId: runMeta.agentId });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!liveness.shouldNudge) {
|
|
230
|
+
return { sent: false, skipped: true };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const lastSupervisorNudgeAtMs = timestampMs(runMeta.lastSupervisorNudgeAt, 0);
|
|
234
|
+
if (lastSupervisorNudgeAtMs > 0 && (Date.now() - lastSupervisorNudgeAtMs) < REPEAT_UPDATE_MS) {
|
|
235
|
+
return { sent: false, skipped: true };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (ledger.currentPhase === 'tool' || ledger.currentPhase === 'model') {
|
|
239
|
+
return sendRuntimeMessagingHeartbeat(engine, runId, { stalled });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (ledger.currentPhase !== 'idle') {
|
|
243
|
+
return { sent: false, skipped: true };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const queued = engine.enqueueSystemSteering(runId, buildProgressNudge({ stalled }), {
|
|
247
|
+
reason: stalled ? 'stalled_progress_check' : 'progress_check',
|
|
248
|
+
});
|
|
249
|
+
if (!queued) {
|
|
250
|
+
return { sent: false, skipped: true };
|
|
251
|
+
}
|
|
252
|
+
runMeta.lastSupervisorNudgeAt = isoNow();
|
|
253
|
+
engine.updateRunProgress(runId, {
|
|
254
|
+
lastUserVisibleUpdateAt: ledger.lastUserVisibleUpdateAt || null,
|
|
255
|
+
});
|
|
256
|
+
return { sent: false, queued: true };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function startMessagingProgressSupervisor(engine, runId) {
|
|
260
|
+
const runMeta = engine.getRunMeta(runId);
|
|
261
|
+
if (!runMeta || runMeta.triggerSource !== 'messaging' || !runMeta.messagingContext?.platform || !runMeta.messagingContext?.chatId) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
if (runMeta.messagingProgressSupervisor?.timer) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
const timer = setInterval(() => {
|
|
268
|
+
tickMessagingProgressSupervisor(engine, runId).catch((error) => {
|
|
269
|
+
console.warn('[Engine] Messaging progress supervisor failed:', error?.message || error);
|
|
270
|
+
});
|
|
271
|
+
}, TICK_MS);
|
|
272
|
+
timer.unref?.();
|
|
273
|
+
runMeta.messagingProgressSupervisor = { timer };
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function stopMessagingProgressSupervisor(engine, runId) {
|
|
278
|
+
const runMeta = engine.getRunMeta(runId);
|
|
279
|
+
const timer = runMeta?.messagingProgressSupervisor?.timer || null;
|
|
280
|
+
if (timer) {
|
|
281
|
+
clearInterval(timer);
|
|
282
|
+
}
|
|
283
|
+
if (runMeta?.messagingProgressSupervisor) {
|
|
284
|
+
runMeta.messagingProgressSupervisor = null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
deliverMessagingFinalFallback,
|
|
290
|
+
requireSuccessfulMessagingDelivery,
|
|
291
|
+
sendRuntimeMessagingHeartbeat,
|
|
292
|
+
shouldSendMessagingFinalFallback,
|
|
293
|
+
startMessagingProgressSupervisor,
|
|
294
|
+
stopMessagingProgressSupervisor,
|
|
295
|
+
tickMessagingProgressSupervisor,
|
|
296
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { sanitizeConversationMessages } = require('../history');
|
|
4
|
+
const { sanitizeModelOutput } = require('../outputSanitizer');
|
|
5
|
+
const { parseJsonObject } = require('../taskAnalysis');
|
|
6
|
+
const { withProviderRetry, isTransientError } = require('../providerRetry');
|
|
7
|
+
const { normalizeUsage, recordModelUsage } = require('../usage');
|
|
8
|
+
|
|
9
|
+
const MODEL_CALL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
function isoNow() {
|
|
12
|
+
return new Date().toISOString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatElapsedDuration(durationMs) {
|
|
16
|
+
const totalSeconds = Math.max(1, Math.floor(Number(durationMs || 0) / 1000));
|
|
17
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
18
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
19
|
+
const seconds = totalSeconds % 60;
|
|
20
|
+
if (seconds === 0) return `${minutes}m`;
|
|
21
|
+
return `${minutes}m ${seconds}s`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveModelCallTimeoutMs(options = {}) {
|
|
25
|
+
const requested = Number(options?.modelCallTimeoutMs);
|
|
26
|
+
if (Number.isFinite(requested) && requested > 0) {
|
|
27
|
+
return Math.max(10, requested);
|
|
28
|
+
}
|
|
29
|
+
return MODEL_CALL_TIMEOUT_MS;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function withModelCallTimeout(promise, options = {}, label = 'Model call') {
|
|
33
|
+
const timeoutMs = resolveModelCallTimeoutMs(options);
|
|
34
|
+
let timer = null;
|
|
35
|
+
const timeout = new Promise((_, reject) => {
|
|
36
|
+
timer = setTimeout(() => {
|
|
37
|
+
const error = new Error(`${label} timed out after ${formatElapsedDuration(timeoutMs)}.`);
|
|
38
|
+
error.code = 'MODEL_CALL_TIMEOUT';
|
|
39
|
+
reject(error);
|
|
40
|
+
}, timeoutMs);
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
return await Promise.race([Promise.resolve(promise), timeout]);
|
|
44
|
+
} finally {
|
|
45
|
+
if (timer) clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function requestStructuredJson(engine, {
|
|
50
|
+
provider,
|
|
51
|
+
providerName,
|
|
52
|
+
model,
|
|
53
|
+
messages,
|
|
54
|
+
prompt,
|
|
55
|
+
maxTokens = 1400,
|
|
56
|
+
normalize,
|
|
57
|
+
fallback = {},
|
|
58
|
+
reasoningEffort,
|
|
59
|
+
telemetry = null,
|
|
60
|
+
phase = 'structured',
|
|
61
|
+
}) {
|
|
62
|
+
const startedAt = Date.now();
|
|
63
|
+
const structuredStep = `model:${phase}`;
|
|
64
|
+
if (telemetry?.runId) {
|
|
65
|
+
engine.updateRunProgress(telemetry.runId, {
|
|
66
|
+
currentPhase: 'model',
|
|
67
|
+
currentStep: structuredStep,
|
|
68
|
+
currentTool: null,
|
|
69
|
+
currentStepStartedAt: isoNow(),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let completed = false;
|
|
74
|
+
try {
|
|
75
|
+
const response = await withProviderRetry(
|
|
76
|
+
() => withModelCallTimeout(
|
|
77
|
+
provider.chat(
|
|
78
|
+
sanitizeConversationMessages([
|
|
79
|
+
...messages,
|
|
80
|
+
{ role: 'system', content: prompt },
|
|
81
|
+
]),
|
|
82
|
+
[],
|
|
83
|
+
{
|
|
84
|
+
model,
|
|
85
|
+
maxTokens,
|
|
86
|
+
reasoningEffort: reasoningEffort || engine.getReasoningEffort(providerName, {}),
|
|
87
|
+
}
|
|
88
|
+
),
|
|
89
|
+
telemetry || {},
|
|
90
|
+
`${phase} model call`,
|
|
91
|
+
),
|
|
92
|
+
{ label: `Engine ${model} (structured)` }
|
|
93
|
+
);
|
|
94
|
+
completed = true;
|
|
95
|
+
if (telemetry?.runId && telemetry?.userId) {
|
|
96
|
+
recordModelUsage({
|
|
97
|
+
runId: telemetry.runId,
|
|
98
|
+
stepId: telemetry.stepId || null,
|
|
99
|
+
userId: telemetry.userId,
|
|
100
|
+
agentId: telemetry.agentId || null,
|
|
101
|
+
provider: providerName,
|
|
102
|
+
model,
|
|
103
|
+
phase,
|
|
104
|
+
usage: response.usage,
|
|
105
|
+
latencyMs: Date.now() - startedAt,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const parsed = parseJsonObject(response.content || '');
|
|
110
|
+
const normalizedUsage = normalizeUsage(response.usage);
|
|
111
|
+
return {
|
|
112
|
+
value: normalize(parsed || {}, fallback),
|
|
113
|
+
raw: response.content || '',
|
|
114
|
+
usage: normalizedUsage?.totalTokens || 0,
|
|
115
|
+
};
|
|
116
|
+
} finally {
|
|
117
|
+
const runMeta = telemetry?.runId ? engine.getRunMeta(telemetry.runId) : null;
|
|
118
|
+
if (runMeta?.progressLedger?.currentStep === structuredStep) {
|
|
119
|
+
engine.updateRunProgress(telemetry.runId, {
|
|
120
|
+
currentPhase: 'idle',
|
|
121
|
+
currentStep: null,
|
|
122
|
+
currentTool: null,
|
|
123
|
+
currentStepStartedAt: null,
|
|
124
|
+
}, {
|
|
125
|
+
verified: completed,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function requestModelResponse(engine, {
|
|
132
|
+
provider,
|
|
133
|
+
providerName,
|
|
134
|
+
model,
|
|
135
|
+
messages,
|
|
136
|
+
tools,
|
|
137
|
+
options,
|
|
138
|
+
runId,
|
|
139
|
+
iteration,
|
|
140
|
+
}) {
|
|
141
|
+
const startedAt = Date.now();
|
|
142
|
+
const requestMessages = sanitizeConversationMessages(messages);
|
|
143
|
+
const callOptions = {
|
|
144
|
+
model,
|
|
145
|
+
reasoningEffort: engine.getReasoningEffort(providerName, options),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const attemptModelCall = async () => {
|
|
149
|
+
let response = null;
|
|
150
|
+
let streamContent = '';
|
|
151
|
+
|
|
152
|
+
if (options.stream !== false) {
|
|
153
|
+
let emittedContent = false;
|
|
154
|
+
const stream = provider.stream(requestMessages, tools, callOptions);
|
|
155
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
156
|
+
try {
|
|
157
|
+
while (true) {
|
|
158
|
+
const next = await withModelCallTimeout(
|
|
159
|
+
iterator.next(),
|
|
160
|
+
options,
|
|
161
|
+
`Model stream iteration ${iteration}`,
|
|
162
|
+
);
|
|
163
|
+
if (next.done) break;
|
|
164
|
+
const chunk = next.value;
|
|
165
|
+
if (chunk.type === 'content') {
|
|
166
|
+
emittedContent = true;
|
|
167
|
+
streamContent += chunk.content;
|
|
168
|
+
engine.emit(options.userId, 'run:stream', {
|
|
169
|
+
runId,
|
|
170
|
+
content: sanitizeModelOutput(streamContent, { model }),
|
|
171
|
+
iteration,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (chunk.type === 'done') {
|
|
175
|
+
response = chunk;
|
|
176
|
+
}
|
|
177
|
+
if (chunk.type === 'tool_calls') {
|
|
178
|
+
response = {
|
|
179
|
+
content: chunk.content || streamContent,
|
|
180
|
+
toolCalls: chunk.toolCalls,
|
|
181
|
+
providerContentBlocks: chunk.providerContentBlocks || null,
|
|
182
|
+
finishReason: 'tool_calls',
|
|
183
|
+
usage: chunk.usage || null,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
Promise.resolve(iterator.return?.()).catch(() => {});
|
|
189
|
+
// Once tokens have streamed to the client a retry would duplicate
|
|
190
|
+
// output, so only the pre-stream window is safe to replay.
|
|
191
|
+
if (emittedContent) err.__providerRetryUnsafe = true;
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
response = await withModelCallTimeout(
|
|
196
|
+
provider.chat(requestMessages, tools, callOptions),
|
|
197
|
+
options,
|
|
198
|
+
`Model iteration ${iteration}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { response, streamContent };
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const { response, streamContent } = await withProviderRetry(attemptModelCall, {
|
|
206
|
+
...(options.retry || {}),
|
|
207
|
+
label: `Engine ${model}`,
|
|
208
|
+
isRetryable: (err) => !err?.__providerRetryUnsafe && isTransientError(err),
|
|
209
|
+
onRetry: ({ attempt, delayMs }) => {
|
|
210
|
+
engine.emit(options.userId, 'run:interim', {
|
|
211
|
+
runId,
|
|
212
|
+
message: `Model service busy; retrying (attempt ${attempt}) in ${Math.max(1, Math.round(delayMs / 1000))}s.`,
|
|
213
|
+
phase: 'recovering',
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const resolvedResponse = response || {
|
|
219
|
+
content: streamContent,
|
|
220
|
+
toolCalls: [],
|
|
221
|
+
finishReason: 'stop',
|
|
222
|
+
usage: null,
|
|
223
|
+
};
|
|
224
|
+
const hasContent = Boolean(String(resolvedResponse.content || streamContent || '').trim());
|
|
225
|
+
const hasToolCalls = Array.isArray(resolvedResponse.toolCalls) && resolvedResponse.toolCalls.length > 0;
|
|
226
|
+
if (!hasContent && !hasToolCalls) {
|
|
227
|
+
const error = new Error(`Model ${model} returned an empty response.`);
|
|
228
|
+
error.code = 'MODEL_EMPTY_RESPONSE';
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
if (options.runId && options.userId) {
|
|
232
|
+
recordModelUsage({
|
|
233
|
+
runId: options.runId,
|
|
234
|
+
stepId: options.stepId || null,
|
|
235
|
+
userId: options.userId,
|
|
236
|
+
agentId: options.agentId || null,
|
|
237
|
+
provider: providerName,
|
|
238
|
+
model,
|
|
239
|
+
phase: options.phase || 'model_turn',
|
|
240
|
+
usage: resolvedResponse.usage,
|
|
241
|
+
latencyMs: Date.now() - startedAt,
|
|
242
|
+
metadata: { iteration },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
response: resolvedResponse,
|
|
248
|
+
responseModel: model,
|
|
249
|
+
streamContent,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
requestModelResponse,
|
|
255
|
+
requestStructuredJson,
|
|
256
|
+
resolveModelCallTimeoutMs,
|
|
257
|
+
withModelCallTimeout,
|
|
258
|
+
};
|