@cereworker/core 26.329.24 → 26.329.26
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/dist/events.d.ts +18 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator.d.ts +18 -2
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +626 -201
- package/dist/orchestrator.js.map +1 -1
- package/dist/system-prompt.d.ts.map +1 -1
- package/dist/system-prompt.js +3 -0
- package/dist/system-prompt.js.map +1 -1
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/orchestrator.js
CHANGED
|
@@ -9,11 +9,16 @@ import { estimateMessageTokens, shouldCompact, buildCompactionMessages } from '.
|
|
|
9
9
|
import { createAbortError, throwIfAborted } from './abort.js';
|
|
10
10
|
import { ToolRuntime, } from './tool-runtime.js';
|
|
11
11
|
const log = createLogger('orchestrator');
|
|
12
|
+
const TASK_COMPLETE_TOOL = 'task_complete';
|
|
13
|
+
const TASK_BLOCKED_TOOL = 'task_blocked';
|
|
14
|
+
const INTERNAL_TASK_SIGNAL_TOOL_NAMES = new Set([TASK_COMPLETE_TOOL, TASK_BLOCKED_TOOL]);
|
|
15
|
+
const COMPLETION_RETRY_PROMPT = '[Cerebellum] Your last turn ended without a final answer. Continue from where you left off and end by calling task_complete or task_blocked before your final answer.';
|
|
12
16
|
export class Orchestrator extends TypedEventEmitter {
|
|
13
17
|
conversations;
|
|
14
18
|
cerebrum = null;
|
|
15
19
|
cerebellum = null;
|
|
16
20
|
subAgentManager = null;
|
|
21
|
+
internalTools = new Map();
|
|
17
22
|
tools = new Map();
|
|
18
23
|
activeConversationId = null;
|
|
19
24
|
systemContext = null;
|
|
@@ -46,8 +51,12 @@ export class Orchestrator extends TypedEventEmitter {
|
|
|
46
51
|
streamNudgeCount = 0;
|
|
47
52
|
streamStallThreshold = 30_000;
|
|
48
53
|
maxNudgeRetries = 2;
|
|
54
|
+
maxCompletionRetries = 2;
|
|
49
55
|
streamPhase = 'idle';
|
|
50
56
|
activeToolCall = null;
|
|
57
|
+
currentStreamTurn = null;
|
|
58
|
+
currentAttemptCompletionState = null;
|
|
59
|
+
streamAbortGraceMs = 1_000;
|
|
51
60
|
taskConversations = new Map();
|
|
52
61
|
taskRunning = new Set();
|
|
53
62
|
recurringTasks = [];
|
|
@@ -62,13 +71,16 @@ export class Orchestrator extends TypedEventEmitter {
|
|
|
62
71
|
super();
|
|
63
72
|
this.conversations = options?.conversationStore ?? new ConversationStore();
|
|
64
73
|
this.toolRuntime = new ToolRuntime(options?.toolRuntime);
|
|
74
|
+
this.registerInternalTools();
|
|
65
75
|
if (options?.compaction) {
|
|
66
76
|
this.compactionConfig = { ...this.compactionConfig, ...options.compaction };
|
|
67
77
|
}
|
|
68
78
|
if (options?.streamStallThreshold)
|
|
69
79
|
this.streamStallThreshold = options.streamStallThreshold * 1000;
|
|
70
|
-
if (options?.maxNudgeRetries)
|
|
80
|
+
if (options?.maxNudgeRetries) {
|
|
71
81
|
this.maxNudgeRetries = options.maxNudgeRetries;
|
|
82
|
+
this.maxCompletionRetries = options.maxNudgeRetries;
|
|
83
|
+
}
|
|
72
84
|
}
|
|
73
85
|
setCerebrum(cerebrum) {
|
|
74
86
|
this.cerebrum = cerebrum;
|
|
@@ -116,12 +128,108 @@ export class Orchestrator extends TypedEventEmitter {
|
|
|
116
128
|
getConversationStore() {
|
|
117
129
|
return this.conversations;
|
|
118
130
|
}
|
|
131
|
+
registerInternalTools() {
|
|
132
|
+
this.internalTools.set(TASK_COMPLETE_TOOL, {
|
|
133
|
+
description: 'Record that a tool-driven task is complete. Call this once right before your final answer with a concise summary and concrete evidence.',
|
|
134
|
+
parameters: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
summary: { type: 'string', description: 'Short summary of what was completed' },
|
|
138
|
+
evidence: { type: 'string', description: 'Concrete evidence proving completion' },
|
|
139
|
+
},
|
|
140
|
+
required: ['summary', 'evidence'],
|
|
141
|
+
additionalProperties: false,
|
|
142
|
+
},
|
|
143
|
+
execute: async (args) => this.recordCompletionSignal('complete', args),
|
|
144
|
+
});
|
|
145
|
+
this.internalTools.set(TASK_BLOCKED_TOOL, {
|
|
146
|
+
description: 'Record that you are blocked and cannot finish the task. Call this once right before your final answer with the blocker and evidence.',
|
|
147
|
+
parameters: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
blocker: { type: 'string', description: 'Specific blocker preventing completion' },
|
|
151
|
+
evidence: { type: 'string', description: 'Concrete evidence showing the blocker' },
|
|
152
|
+
},
|
|
153
|
+
required: ['blocker', 'evidence'],
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
},
|
|
156
|
+
execute: async (args) => this.recordCompletionSignal('blocked', args),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
getAllTools() {
|
|
160
|
+
return new Map([...this.tools, ...this.internalTools]);
|
|
161
|
+
}
|
|
162
|
+
isInternalTaskSignalTool(name) {
|
|
163
|
+
return INTERNAL_TASK_SIGNAL_TOOL_NAMES.has(name.trim() || name);
|
|
164
|
+
}
|
|
165
|
+
async recordCompletionSignal(signal, args) {
|
|
166
|
+
const state = this.currentAttemptCompletionState;
|
|
167
|
+
if (!state) {
|
|
168
|
+
return {
|
|
169
|
+
output: 'No active turn is available for task completion tracking.',
|
|
170
|
+
isError: true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const evidence = String(args.evidence ?? '').trim();
|
|
174
|
+
if (!evidence) {
|
|
175
|
+
return {
|
|
176
|
+
output: 'A non-empty evidence field is required.',
|
|
177
|
+
isError: true,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (signal === 'complete') {
|
|
181
|
+
const summary = String(args.summary ?? '').trim();
|
|
182
|
+
if (!summary) {
|
|
183
|
+
return {
|
|
184
|
+
output: 'A non-empty summary field is required.',
|
|
185
|
+
isError: true,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (state.successfulExternalToolCount === 0) {
|
|
189
|
+
return {
|
|
190
|
+
output: 'task_complete requires at least one successful external tool result in this attempt.',
|
|
191
|
+
isError: true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
state.signal = 'complete';
|
|
195
|
+
state.summary = summary;
|
|
196
|
+
state.blocker = undefined;
|
|
197
|
+
state.evidence = evidence;
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
const blocker = String(args.blocker ?? '').trim();
|
|
201
|
+
if (!blocker) {
|
|
202
|
+
return {
|
|
203
|
+
output: 'A non-empty blocker field is required.',
|
|
204
|
+
isError: true,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
state.signal = 'blocked';
|
|
208
|
+
state.blocker = blocker;
|
|
209
|
+
state.summary = undefined;
|
|
210
|
+
state.evidence = evidence;
|
|
211
|
+
}
|
|
212
|
+
this.emitCompletionTrace('signal_recorded', signal === 'complete'
|
|
213
|
+
? `Recorded task_complete signal with evidence: ${evidence}`
|
|
214
|
+
: `Recorded task_blocked signal with evidence: ${evidence}`, signal, 'info');
|
|
215
|
+
return {
|
|
216
|
+
output: signal === 'complete' ? 'Task completion recorded.' : 'Task blocker recorded.',
|
|
217
|
+
isError: false,
|
|
218
|
+
metadata: {
|
|
219
|
+
internal: true,
|
|
220
|
+
signal,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
119
224
|
registerTool(name, tool) {
|
|
225
|
+
if (this.internalTools.has(name)) {
|
|
226
|
+
throw new Error(`Tool name ${name} is reserved for internal task signaling`);
|
|
227
|
+
}
|
|
120
228
|
this.tools.set(name, tool);
|
|
121
229
|
}
|
|
122
230
|
registerTools(tools) {
|
|
123
231
|
for (const [name, tool] of Object.entries(tools)) {
|
|
124
|
-
this.
|
|
232
|
+
this.registerTool(name, tool);
|
|
125
233
|
}
|
|
126
234
|
}
|
|
127
235
|
async executeTool(name, args, options) {
|
|
@@ -131,13 +239,16 @@ export class Orchestrator extends TypedEventEmitter {
|
|
|
131
239
|
name,
|
|
132
240
|
args,
|
|
133
241
|
},
|
|
134
|
-
tools: this.
|
|
242
|
+
tools: this.getAllTools(),
|
|
135
243
|
conversationId: options?.conversationId,
|
|
136
244
|
sessionKey: options?.sessionKey,
|
|
137
245
|
scopeKey: options?.scopeKey,
|
|
138
246
|
});
|
|
139
247
|
}
|
|
140
248
|
unregisterTool(name) {
|
|
249
|
+
if (this.internalTools.has(name)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
141
252
|
return this.tools.delete(name);
|
|
142
253
|
}
|
|
143
254
|
setProfile(profile) {
|
|
@@ -454,18 +565,35 @@ export class Orchestrator extends TypedEventEmitter {
|
|
|
454
565
|
};
|
|
455
566
|
}
|
|
456
567
|
markStreamWaitingModel(activityAt = Date.now()) {
|
|
568
|
+
const phaseChanged = this.streamPhase !== 'waiting_model' || this.activeToolCall !== null;
|
|
457
569
|
this.lastStreamActivityAt = activityAt;
|
|
458
570
|
this.streamPhase = 'waiting_model';
|
|
459
571
|
this.activeToolCall = null;
|
|
572
|
+
if (phaseChanged) {
|
|
573
|
+
this.logStreamDebug('stream_phase_changed', {
|
|
574
|
+
phase: this.streamPhase,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
460
577
|
}
|
|
461
578
|
markStreamWaitingTool(toolCall, activityAt = Date.now()) {
|
|
579
|
+
const normalizedToolName = toolCall.name.trim() || toolCall.name;
|
|
580
|
+
const phaseChanged = this.streamPhase !== 'waiting_tool'
|
|
581
|
+
|| this.activeToolCall?.id !== toolCall.id
|
|
582
|
+
|| this.activeToolCall?.name !== normalizedToolName;
|
|
462
583
|
this.lastStreamActivityAt = activityAt;
|
|
463
584
|
this.streamPhase = 'waiting_tool';
|
|
464
585
|
this.activeToolCall = {
|
|
465
586
|
id: toolCall.id,
|
|
466
|
-
name:
|
|
587
|
+
name: normalizedToolName,
|
|
467
588
|
startedAt: activityAt,
|
|
468
589
|
};
|
|
590
|
+
if (phaseChanged) {
|
|
591
|
+
this.logStreamDebug('stream_phase_changed', {
|
|
592
|
+
phase: this.streamPhase,
|
|
593
|
+
activeToolName: normalizedToolName,
|
|
594
|
+
activeToolCallId: toolCall.id,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
469
597
|
}
|
|
470
598
|
resetStreamState() {
|
|
471
599
|
this.streamPhase = 'idle';
|
|
@@ -480,6 +608,162 @@ export class Orchestrator extends TypedEventEmitter {
|
|
|
480
608
|
activeToolStartedAt: this.activeToolCall?.startedAt,
|
|
481
609
|
};
|
|
482
610
|
}
|
|
611
|
+
describeStreamLocation() {
|
|
612
|
+
if (this.streamPhase === 'waiting_tool') {
|
|
613
|
+
return this.activeToolCall?.name
|
|
614
|
+
? `waiting_tool/${this.activeToolCall.name}`
|
|
615
|
+
: 'waiting_tool';
|
|
616
|
+
}
|
|
617
|
+
return this.streamPhase;
|
|
618
|
+
}
|
|
619
|
+
logStreamDebug(msg, data) {
|
|
620
|
+
if (!this.currentStreamTurn)
|
|
621
|
+
return;
|
|
622
|
+
log.debug(msg, {
|
|
623
|
+
turnId: this.currentStreamTurn.turnId,
|
|
624
|
+
attempt: this.currentStreamTurn.attempt,
|
|
625
|
+
conversationId: this.currentStreamTurn.conversationId,
|
|
626
|
+
...data,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
emitWatchdog(stage, message, options) {
|
|
630
|
+
if (!this.currentStreamTurn)
|
|
631
|
+
return;
|
|
632
|
+
const payload = {
|
|
633
|
+
stage,
|
|
634
|
+
turnId: this.currentStreamTurn.turnId,
|
|
635
|
+
attempt: this.currentStreamTurn.attempt,
|
|
636
|
+
conversationId: this.currentStreamTurn.conversationId,
|
|
637
|
+
message,
|
|
638
|
+
...this.getStreamDiagnostics(options?.elapsedSeconds),
|
|
639
|
+
};
|
|
640
|
+
const level = options?.level ?? 'info';
|
|
641
|
+
switch (level) {
|
|
642
|
+
case 'debug':
|
|
643
|
+
log.debug(`watchdog_${stage}`, payload);
|
|
644
|
+
break;
|
|
645
|
+
case 'warn':
|
|
646
|
+
log.warn(`watchdog_${stage}`, payload);
|
|
647
|
+
break;
|
|
648
|
+
case 'error':
|
|
649
|
+
log.error(`watchdog_${stage}`, payload);
|
|
650
|
+
break;
|
|
651
|
+
default:
|
|
652
|
+
log.info(`watchdog_${stage}`, payload);
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
this.emit({ type: 'cerebrum:watchdog', ...payload });
|
|
656
|
+
}
|
|
657
|
+
emitCompletionTrace(stage, message, signal, level = 'info') {
|
|
658
|
+
if (!this.currentStreamTurn)
|
|
659
|
+
return;
|
|
660
|
+
const payload = {
|
|
661
|
+
stage,
|
|
662
|
+
turnId: this.currentStreamTurn.turnId,
|
|
663
|
+
attempt: this.currentStreamTurn.attempt,
|
|
664
|
+
conversationId: this.currentStreamTurn.conversationId,
|
|
665
|
+
signal,
|
|
666
|
+
message,
|
|
667
|
+
...this.getStreamDiagnostics(),
|
|
668
|
+
};
|
|
669
|
+
switch (level) {
|
|
670
|
+
case 'debug':
|
|
671
|
+
log.debug(`completion_${stage}`, payload);
|
|
672
|
+
break;
|
|
673
|
+
case 'warn':
|
|
674
|
+
log.warn(`completion_${stage}`, payload);
|
|
675
|
+
break;
|
|
676
|
+
case 'error':
|
|
677
|
+
log.error(`completion_${stage}`, payload);
|
|
678
|
+
break;
|
|
679
|
+
default:
|
|
680
|
+
log.info(`completion_${stage}`, payload);
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
this.emit({ type: 'cerebrum:completion', ...payload });
|
|
684
|
+
}
|
|
685
|
+
createAttemptCompletionState() {
|
|
686
|
+
return {
|
|
687
|
+
signal: 'none',
|
|
688
|
+
evidence: '',
|
|
689
|
+
successfulExternalToolCount: 0,
|
|
690
|
+
externalToolCallCount: 0,
|
|
691
|
+
internalToolCallCount: 0,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
evaluateCompletionGuard(displayContent, finishMeta, completionState) {
|
|
695
|
+
const trimmedContent = displayContent.trim();
|
|
696
|
+
const hadExternalToolActivity = completionState.externalToolCallCount > 0;
|
|
697
|
+
const endedOnToolCalls = finishMeta?.finishReason === 'tool-calls'
|
|
698
|
+
|| finishMeta?.stepFinishReasons.at(-1) === 'tool-calls';
|
|
699
|
+
if (trimmedContent.length === 0 && hadExternalToolActivity) {
|
|
700
|
+
return {
|
|
701
|
+
message: 'Turn ended after tool activity without a final answer.',
|
|
702
|
+
signal: completionState.signal,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
if (endedOnToolCalls) {
|
|
706
|
+
return {
|
|
707
|
+
message: 'Turn ended on tool-calls without a final answer.',
|
|
708
|
+
signal: completionState.signal,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
if (hadExternalToolActivity && completionState.signal === 'none') {
|
|
712
|
+
return {
|
|
713
|
+
message: 'Tool-driven turn ended without task_complete or task_blocked.',
|
|
714
|
+
signal: completionState.signal,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
async awaitStreamAttempt(streamPromise, abortController) {
|
|
720
|
+
return new Promise((resolve, reject) => {
|
|
721
|
+
let settled = false;
|
|
722
|
+
let abortTimer = null;
|
|
723
|
+
const cleanup = () => {
|
|
724
|
+
abortController.signal.removeEventListener('abort', onAbort);
|
|
725
|
+
if (abortTimer) {
|
|
726
|
+
clearTimeout(abortTimer);
|
|
727
|
+
abortTimer = null;
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
const settleResolve = () => {
|
|
731
|
+
if (settled)
|
|
732
|
+
return;
|
|
733
|
+
settled = true;
|
|
734
|
+
cleanup();
|
|
735
|
+
resolve();
|
|
736
|
+
};
|
|
737
|
+
const settleReject = (error) => {
|
|
738
|
+
if (settled)
|
|
739
|
+
return;
|
|
740
|
+
settled = true;
|
|
741
|
+
cleanup();
|
|
742
|
+
reject(error);
|
|
743
|
+
};
|
|
744
|
+
const onAbort = () => {
|
|
745
|
+
this.logStreamDebug('provider_abort_observed', {
|
|
746
|
+
phase: this.streamPhase,
|
|
747
|
+
activeToolName: this.activeToolCall?.name,
|
|
748
|
+
activeToolCallId: this.activeToolCall?.id,
|
|
749
|
+
});
|
|
750
|
+
if (abortTimer)
|
|
751
|
+
return;
|
|
752
|
+
abortTimer = setTimeout(() => {
|
|
753
|
+
if (settled)
|
|
754
|
+
return;
|
|
755
|
+
const elapsedSeconds = Math.max(1, Math.round((Date.now() - this.lastStreamActivityAt) / 1000));
|
|
756
|
+
this.emitWatchdog('teardown_timeout', `Provider did not settle within ${this.streamAbortGraceMs}ms after abort; continuing retry.`, { level: 'warn', elapsedSeconds });
|
|
757
|
+
settleReject(createAbortError('Stream aborted'));
|
|
758
|
+
}, this.streamAbortGraceMs);
|
|
759
|
+
};
|
|
760
|
+
abortController.signal.addEventListener('abort', onAbort, { once: true });
|
|
761
|
+
if (abortController.signal.aborted) {
|
|
762
|
+
onAbort();
|
|
763
|
+
}
|
|
764
|
+
streamPromise.then(settleResolve, settleReject);
|
|
765
|
+
});
|
|
766
|
+
}
|
|
483
767
|
startStreamWatchdog() {
|
|
484
768
|
this.stopStreamWatchdog();
|
|
485
769
|
this.markStreamWaitingModel();
|
|
@@ -493,19 +777,20 @@ export class Orchestrator extends TypedEventEmitter {
|
|
|
493
777
|
return;
|
|
494
778
|
const elapsedSeconds = Math.round(elapsed / 1000);
|
|
495
779
|
const diagnostics = this.getStreamDiagnostics(elapsedSeconds);
|
|
496
|
-
|
|
780
|
+
this.emitWatchdog('stalled', `Stalled after ${elapsedSeconds}s while ${this.describeStreamLocation()}.`, { level: 'warn', elapsedSeconds });
|
|
497
781
|
this.emit({ type: 'cerebrum:stall', ...diagnostics });
|
|
498
782
|
if (!this.cerebellum?.isConnected()) {
|
|
499
783
|
// Cerebellum dropped mid-stream — abort the current turn
|
|
500
|
-
|
|
784
|
+
this.emitWatchdog('abort_issued', 'Cerebellum disconnected during an active stream; aborting the turn.', { level: 'warn', elapsedSeconds });
|
|
501
785
|
this.abortController?.abort();
|
|
502
786
|
return;
|
|
503
787
|
}
|
|
504
788
|
this._nudgeInFlight = true;
|
|
505
789
|
const doNudge = () => {
|
|
506
790
|
this.streamNudgeCount++;
|
|
507
|
-
|
|
791
|
+
this.emitWatchdog('nudge_requested', `Cerebellum requested nudge ${this.streamNudgeCount}/${this.maxNudgeRetries} after ${elapsedSeconds}s while ${this.describeStreamLocation()}.`, { level: 'info', elapsedSeconds });
|
|
508
792
|
this.emit({ type: 'cerebrum:stall:nudge', attempt: this.streamNudgeCount, ...diagnostics });
|
|
793
|
+
this.emitWatchdog('abort_issued', `Aborting stalled stream attempt ${this.currentStreamTurn?.attempt ?? 0}.`, { level: 'warn', elapsedSeconds });
|
|
509
794
|
this.abortController?.abort();
|
|
510
795
|
};
|
|
511
796
|
void (async () => {
|
|
@@ -549,225 +834,365 @@ export class Orchestrator extends TypedEventEmitter {
|
|
|
549
834
|
this.emit({ type: 'message:user', message: userMessage });
|
|
550
835
|
}
|
|
551
836
|
this.streamNudgeCount = 0;
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
837
|
+
let completionRetryCount = 0;
|
|
838
|
+
const turnId = nanoid(10);
|
|
839
|
+
const maxTotalAttempts = 1 + this.maxNudgeRetries + this.maxCompletionRetries;
|
|
840
|
+
let loopTerminated = false;
|
|
841
|
+
let nextRetryCause = null;
|
|
842
|
+
try {
|
|
843
|
+
for (let attempt = 0; attempt < maxTotalAttempts; attempt++) {
|
|
844
|
+
const abortController = new AbortController();
|
|
845
|
+
const attemptNumber = attempt + 1;
|
|
846
|
+
const retryCause = nextRetryCause;
|
|
847
|
+
nextRetryCause = null;
|
|
848
|
+
const completionState = this.createAttemptCompletionState();
|
|
849
|
+
let completionGuardFailure = null;
|
|
850
|
+
const stallRetryCountAtStart = this.streamNudgeCount;
|
|
851
|
+
const isCurrentAttempt = () => this.abortController === abortController;
|
|
852
|
+
this.abortController = abortController;
|
|
853
|
+
this.currentAttemptCompletionState = completionState;
|
|
854
|
+
this.currentStreamTurn = {
|
|
855
|
+
turnId,
|
|
856
|
+
attempt: attemptNumber,
|
|
857
|
+
conversationId: convId,
|
|
858
|
+
};
|
|
859
|
+
log.info('stream_started', {
|
|
860
|
+
turnId,
|
|
861
|
+
attempt: attemptNumber,
|
|
560
862
|
conversationId: convId,
|
|
863
|
+
stallRetryCount: this.streamNudgeCount,
|
|
864
|
+
completionRetryCount,
|
|
865
|
+
retryCause,
|
|
561
866
|
});
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
867
|
+
this.emit({ type: 'message:cerebrum:start', conversationId: convId });
|
|
868
|
+
this.startStreamWatchdog();
|
|
869
|
+
let messages = this.conversations.getMessages(convId);
|
|
870
|
+
// Context window compaction
|
|
871
|
+
if (this.compactionConfig.enabled &&
|
|
872
|
+
this.cerebrum?.summarize &&
|
|
873
|
+
shouldCompact(messages, this.compactionConfig.contextWindow, this.compactionConfig.threshold)) {
|
|
874
|
+
try {
|
|
875
|
+
const keepRecent = this.compactionConfig.keepRecentMessages;
|
|
876
|
+
const olderMessages = messages.slice(0, Math.max(0, messages.length - keepRecent));
|
|
877
|
+
if (olderMessages.length > 0) {
|
|
878
|
+
log.info('Compacting conversation', {
|
|
879
|
+
totalMessages: messages.length,
|
|
880
|
+
compactingMessages: olderMessages.length,
|
|
881
|
+
estimatedTokens: estimateMessageTokens(messages),
|
|
882
|
+
});
|
|
883
|
+
const summary = await this.cerebrum.summarize(olderMessages);
|
|
884
|
+
messages = buildCompactionMessages(messages, summary, keepRecent);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
log.warn('Compaction failed, continuing with full context', {
|
|
889
|
+
error: error instanceof Error ? error.message : String(error),
|
|
578
890
|
});
|
|
579
|
-
const summary = await this.cerebrum.summarize(olderMessages);
|
|
580
|
-
messages = buildCompactionMessages(messages, summary, keepRecent);
|
|
581
891
|
}
|
|
582
892
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
finetuneStatus: {
|
|
600
|
-
enabled: !!this.fineTuneDataProvider,
|
|
601
|
-
status: this.fineTuneStatus.status,
|
|
602
|
-
progress: this.fineTuneStatus.progress,
|
|
603
|
-
lastJobId: this.fineTuneStatus.jobId || undefined,
|
|
604
|
-
},
|
|
605
|
-
recurringTasks: this.recurringTasks,
|
|
606
|
-
instanceId: instance?.id,
|
|
607
|
-
instanceCreatedAt: instance?.createdAt,
|
|
608
|
-
finetuneCount: instance?.finetuneLineage.length,
|
|
609
|
-
proactiveEnabled: this.proactiveEnabled,
|
|
610
|
-
discoveryMode: this.discoveryMode,
|
|
611
|
-
});
|
|
612
|
-
const systemParts = [basePrompt];
|
|
613
|
-
if (this.systemContext)
|
|
614
|
-
systemParts.push(this.systemContext);
|
|
615
|
-
const fullSystemPrompt = systemParts.join('\n\n---\n\n');
|
|
616
|
-
const allMessages = [
|
|
617
|
-
{ id: 'system', role: 'system', content: fullSystemPrompt, timestamp: 0 },
|
|
618
|
-
...messages,
|
|
619
|
-
];
|
|
620
|
-
const toolDefs = Object.fromEntries(this.tools);
|
|
621
|
-
let fullContent = '';
|
|
622
|
-
const throwIfToolAttemptAborted = () => {
|
|
623
|
-
if (!isCurrentAttempt()) {
|
|
624
|
-
throw createAbortError('Tool execution aborted');
|
|
625
|
-
}
|
|
626
|
-
throwIfAborted(abortController.signal, 'Tool execution aborted');
|
|
627
|
-
};
|
|
628
|
-
try {
|
|
629
|
-
await this.cerebrum.stream(allMessages, toolDefs, {
|
|
630
|
-
onChunk: (chunk) => {
|
|
631
|
-
if (!isCurrentAttempt() || abortController.signal.aborted)
|
|
632
|
-
return;
|
|
633
|
-
fullContent += chunk;
|
|
634
|
-
this.markStreamWaitingModel();
|
|
635
|
-
this.emit({ type: 'message:cerebrum:chunk', chunk });
|
|
893
|
+
// Build system prompt with runtime state + skills context
|
|
894
|
+
const instance = this.instanceStore?.get();
|
|
895
|
+
const allTools = this.getAllTools();
|
|
896
|
+
const basePrompt = buildSystemPrompt({
|
|
897
|
+
cerebellumConnected: this.cerebellum?.isConnected() ?? false,
|
|
898
|
+
tools: allTools,
|
|
899
|
+
autoMode: this.autoMode,
|
|
900
|
+
gatewayMode: this.gatewayMode,
|
|
901
|
+
connectedNodes: this.connectedNodes,
|
|
902
|
+
gatewayUrl: this.gatewayUrl,
|
|
903
|
+
profile: this.profile,
|
|
904
|
+
finetuneStatus: {
|
|
905
|
+
enabled: !!this.fineTuneDataProvider,
|
|
906
|
+
status: this.fineTuneStatus.status,
|
|
907
|
+
progress: this.fineTuneStatus.progress,
|
|
908
|
+
lastJobId: this.fineTuneStatus.jobId || undefined,
|
|
636
909
|
},
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
910
|
+
recurringTasks: this.recurringTasks,
|
|
911
|
+
instanceId: instance?.id,
|
|
912
|
+
instanceCreatedAt: instance?.createdAt,
|
|
913
|
+
finetuneCount: instance?.finetuneLineage.length,
|
|
914
|
+
proactiveEnabled: this.proactiveEnabled,
|
|
915
|
+
discoveryMode: this.discoveryMode,
|
|
916
|
+
});
|
|
917
|
+
const systemParts = [basePrompt];
|
|
918
|
+
if (this.systemContext)
|
|
919
|
+
systemParts.push(this.systemContext);
|
|
920
|
+
const fullSystemPrompt = systemParts.join('\n\n---\n\n');
|
|
921
|
+
const allMessages = [
|
|
922
|
+
{ id: 'system', role: 'system', content: fullSystemPrompt, timestamp: 0 },
|
|
923
|
+
...messages,
|
|
924
|
+
];
|
|
925
|
+
const toolDefs = Object.fromEntries(allTools);
|
|
926
|
+
let fullContent = '';
|
|
927
|
+
const throwIfToolAttemptAborted = () => {
|
|
928
|
+
if (!isCurrentAttempt()) {
|
|
929
|
+
throw createAbortError('Tool execution aborted');
|
|
930
|
+
}
|
|
931
|
+
throwIfAborted(abortController.signal, 'Tool execution aborted');
|
|
932
|
+
};
|
|
933
|
+
try {
|
|
934
|
+
const streamPromise = this.cerebrum.stream(allMessages, toolDefs, {
|
|
935
|
+
onChunk: (chunk) => {
|
|
936
|
+
if (!isCurrentAttempt() || abortController.signal.aborted)
|
|
937
|
+
return;
|
|
938
|
+
fullContent += chunk;
|
|
939
|
+
this.markStreamWaitingModel();
|
|
940
|
+
this.emit({ type: 'message:cerebrum:chunk', chunk });
|
|
941
|
+
},
|
|
942
|
+
onToolCall: async (toolCall) => {
|
|
943
|
+
throwIfToolAttemptAborted();
|
|
944
|
+
this.logStreamDebug('tool_callback_started', {
|
|
945
|
+
toolName: toolCall.name.trim() || toolCall.name,
|
|
946
|
+
toolCallId: toolCall.id,
|
|
947
|
+
});
|
|
948
|
+
this.markStreamWaitingTool(toolCall);
|
|
949
|
+
const requestedToolName = toolCall.name;
|
|
950
|
+
const normalizedToolName = requestedToolName.trim() || requestedToolName;
|
|
951
|
+
const isInternalTaskSignal = this.isInternalTaskSignalTool(normalizedToolName);
|
|
952
|
+
if (isInternalTaskSignal) {
|
|
953
|
+
completionState.internalToolCallCount++;
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
completionState.externalToolCallCount++;
|
|
957
|
+
this.emit({ type: 'message:cerebrum:toolcall', toolCall: { ...toolCall, name: normalizedToolName } });
|
|
958
|
+
this.emit({ type: 'tool:start', callId: toolCall.id, name: normalizedToolName });
|
|
959
|
+
}
|
|
960
|
+
const { toolName, result } = await this.toolRuntime.execute({
|
|
961
|
+
toolCall,
|
|
962
|
+
tools: allTools,
|
|
963
|
+
conversationId: convId,
|
|
964
|
+
sessionKey: 'agent:main',
|
|
965
|
+
scopeKey: convId,
|
|
966
|
+
abortSignal: abortController.signal,
|
|
967
|
+
});
|
|
968
|
+
this.logStreamDebug('tool_callback_finished', {
|
|
969
|
+
toolName,
|
|
970
|
+
toolCallId: toolCall.id,
|
|
971
|
+
isError: result.isError,
|
|
972
|
+
});
|
|
973
|
+
throwIfAborted(abortController.signal, 'Tool execution aborted');
|
|
974
|
+
this.markStreamWaitingModel();
|
|
975
|
+
if (!isInternalTaskSignal) {
|
|
976
|
+
this.emit({ type: 'tool:end', result });
|
|
977
|
+
}
|
|
978
|
+
if (!isInternalTaskSignal && !result.isError) {
|
|
979
|
+
completionState.successfulExternalToolCount++;
|
|
980
|
+
}
|
|
981
|
+
// Cerebellum verification (non-blocking)
|
|
982
|
+
if (!isInternalTaskSignal && this.cerebellum?.isConnected() && this.verificationEnabled) {
|
|
983
|
+
try {
|
|
984
|
+
throwIfAborted(abortController.signal, 'Tool execution aborted');
|
|
985
|
+
this.emit({ type: 'verification:start', callId: toolCall.id, toolName });
|
|
986
|
+
const toolArgs = {};
|
|
987
|
+
for (const [k, v] of Object.entries(toolCall.args)) {
|
|
988
|
+
toolArgs[k] = String(v);
|
|
989
|
+
}
|
|
990
|
+
const verifyPromise = this.cerebellum.verifyToolResult(toolName, toolArgs, result.output, !result.isError);
|
|
991
|
+
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), this.verificationTimeoutMs));
|
|
992
|
+
const verification = await Promise.race([verifyPromise, timeoutPromise]);
|
|
993
|
+
throwIfAborted(abortController.signal, 'Tool execution aborted');
|
|
994
|
+
if (verification && !verification.passed) {
|
|
995
|
+
const failedChecks = verification.checks
|
|
996
|
+
.filter((c) => !c.passed)
|
|
997
|
+
.map((c) => c.description)
|
|
998
|
+
.join(', ');
|
|
999
|
+
result.output += `\n[Cerebellum warning: ${failedChecks}]`;
|
|
1000
|
+
}
|
|
1001
|
+
if (verification) {
|
|
1002
|
+
const vResult = {
|
|
1003
|
+
passed: verification.passed,
|
|
1004
|
+
checks: verification.checks,
|
|
1005
|
+
modelVerdict: verification.modelVerdict,
|
|
1006
|
+
toolCallId: toolCall.id,
|
|
1007
|
+
toolName,
|
|
1008
|
+
};
|
|
1009
|
+
this.emit({ type: 'verification:end', result: vResult });
|
|
1010
|
+
}
|
|
663
1011
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const verification = await Promise.race([verifyPromise, timeoutPromise]);
|
|
667
|
-
throwIfAborted(abortController.signal, 'Tool execution aborted');
|
|
668
|
-
if (verification && !verification.passed) {
|
|
669
|
-
const failedChecks = verification.checks
|
|
670
|
-
.filter((c) => !c.passed)
|
|
671
|
-
.map((c) => c.description)
|
|
672
|
-
.join(', ');
|
|
673
|
-
result.output += `\n[Cerebellum warning: ${failedChecks}]`;
|
|
1012
|
+
catch {
|
|
1013
|
+
// Verification failure should never block tool execution
|
|
674
1014
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1015
|
+
}
|
|
1016
|
+
throwIfToolAttemptAborted();
|
|
1017
|
+
if (!isInternalTaskSignal) {
|
|
1018
|
+
this.conversations.appendMessage(convId, 'tool', result.output, {
|
|
1019
|
+
toolResult: result,
|
|
1020
|
+
metadata: {
|
|
681
1021
|
toolName,
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
}
|
|
1022
|
+
...(requestedToolName !== toolName ? { requestedToolName } : {}),
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
685
1025
|
}
|
|
686
|
-
|
|
687
|
-
|
|
1026
|
+
return result;
|
|
1027
|
+
},
|
|
1028
|
+
onFinish: (content, toolCalls, finishMeta) => {
|
|
1029
|
+
if (!isCurrentAttempt() || abortController.signal.aborted)
|
|
1030
|
+
return;
|
|
1031
|
+
this.stopStreamWatchdog();
|
|
1032
|
+
let displayContent = content;
|
|
1033
|
+
const visibleToolCalls = toolCalls?.filter((toolCall) => !this.isInternalTaskSignalTool(toolCall.name));
|
|
1034
|
+
log.info('stream_finish_observed', {
|
|
1035
|
+
turnId,
|
|
1036
|
+
attempt: attemptNumber,
|
|
1037
|
+
conversationId: convId,
|
|
1038
|
+
finishReason: finishMeta?.finishReason,
|
|
1039
|
+
rawFinishReason: finishMeta?.rawFinishReason,
|
|
1040
|
+
stepCount: finishMeta?.stepCount ?? 0,
|
|
1041
|
+
chunkCount: finishMeta?.chunkCount ?? 0,
|
|
1042
|
+
toolCallCount: finishMeta?.toolCallCount ?? 0,
|
|
1043
|
+
textChars: finishMeta?.textChars ?? content.length,
|
|
1044
|
+
completionSignal: completionState.signal,
|
|
1045
|
+
});
|
|
1046
|
+
// Check for discovery completion — parse and strip the tag before storing
|
|
1047
|
+
if (this.discoveryMode && content.includes('<discovery_complete>')) {
|
|
1048
|
+
const parsed = this.parseDiscoveryCompletion(content);
|
|
1049
|
+
// Strip the tag block from the displayed/stored content
|
|
1050
|
+
displayContent = content
|
|
1051
|
+
.replace(/<discovery_complete>[\s\S]*?<\/discovery_complete>/g, '')
|
|
1052
|
+
.trim();
|
|
1053
|
+
if (parsed && this.onDiscoveryComplete) {
|
|
1054
|
+
this.discoveryMode = false;
|
|
1055
|
+
this.onDiscoveryComplete(parsed);
|
|
1056
|
+
log.info('Discovery completed', { name: parsed.name });
|
|
1057
|
+
}
|
|
688
1058
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
if (this.discoveryMode && content.includes('<discovery_complete>')) {
|
|
707
|
-
const parsed = this.parseDiscoveryCompletion(content);
|
|
708
|
-
// Strip the tag block from the displayed/stored content
|
|
709
|
-
displayContent = content
|
|
710
|
-
.replace(/<discovery_complete>[\s\S]*?<\/discovery_complete>/g, '')
|
|
711
|
-
.trim();
|
|
712
|
-
if (parsed && this.onDiscoveryComplete) {
|
|
713
|
-
this.discoveryMode = false;
|
|
714
|
-
this.onDiscoveryComplete(parsed);
|
|
715
|
-
log.info('Discovery completed', { name: parsed.name });
|
|
1059
|
+
const guardFailure = this.evaluateCompletionGuard(displayContent, finishMeta, completionState);
|
|
1060
|
+
if (guardFailure) {
|
|
1061
|
+
completionGuardFailure = guardFailure;
|
|
1062
|
+
this.emitCompletionTrace('guard_triggered', guardFailure.message, guardFailure.signal, 'warn');
|
|
1063
|
+
log.warn('completion_guard_triggered', {
|
|
1064
|
+
turnId,
|
|
1065
|
+
attempt: attemptNumber,
|
|
1066
|
+
conversationId: convId,
|
|
1067
|
+
finishReason: finishMeta?.finishReason,
|
|
1068
|
+
rawFinishReason: finishMeta?.rawFinishReason,
|
|
1069
|
+
stepCount: finishMeta?.stepCount ?? 0,
|
|
1070
|
+
chunkCount: finishMeta?.chunkCount ?? 0,
|
|
1071
|
+
toolCallCount: finishMeta?.toolCallCount ?? 0,
|
|
1072
|
+
textChars: finishMeta?.textChars ?? displayContent.length,
|
|
1073
|
+
completionSignal: completionState.signal,
|
|
1074
|
+
});
|
|
1075
|
+
return;
|
|
716
1076
|
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
attempt,
|
|
1077
|
+
const cerebrumMessage = this.conversations.appendMessage(convId, 'cerebrum', displayContent, visibleToolCalls?.length ? { toolCalls: visibleToolCalls } : undefined);
|
|
1078
|
+
this.emit({ type: 'message:cerebrum:end', message: cerebrumMessage });
|
|
1079
|
+
log.info('stream_finished', {
|
|
1080
|
+
turnId,
|
|
1081
|
+
attempt: attemptNumber,
|
|
723
1082
|
conversationId: convId,
|
|
1083
|
+
stallRetryCount: this.streamNudgeCount,
|
|
1084
|
+
completionRetryCount,
|
|
1085
|
+
retryCause,
|
|
724
1086
|
});
|
|
1087
|
+
if (retryCause === 'completion') {
|
|
1088
|
+
this.emitCompletionTrace('retry_recovered', `Completion retry ${completionRetryCount}/${this.maxCompletionRetries} recovered on attempt ${attemptNumber}.`, completionState.signal, 'info');
|
|
1089
|
+
}
|
|
1090
|
+
else if (retryCause === 'stall') {
|
|
1091
|
+
this.emitWatchdog('retry_recovered', `Stall retry ${this.streamNudgeCount}/${this.maxNudgeRetries} recovered on attempt ${attemptNumber}.`, { level: 'info' });
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
onError: (error) => {
|
|
1095
|
+
if (!isCurrentAttempt())
|
|
1096
|
+
return;
|
|
1097
|
+
this.stopStreamWatchdog();
|
|
1098
|
+
// Don't log/emit if the abort was intentional (nudge or Cerebellum disconnect) — catch block handles it
|
|
1099
|
+
if (abortController.signal.aborted)
|
|
1100
|
+
return;
|
|
1101
|
+
log.error('Cerebrum stream error', { error: error.message });
|
|
1102
|
+
this.emit({ type: 'error', error });
|
|
1103
|
+
},
|
|
1104
|
+
}, { abortSignal: abortController.signal });
|
|
1105
|
+
await this.awaitStreamAttempt(streamPromise, abortController);
|
|
1106
|
+
const completionFailure = completionGuardFailure;
|
|
1107
|
+
if (completionFailure !== null) {
|
|
1108
|
+
const completionSignal = completionFailure.signal;
|
|
1109
|
+
if (completionRetryCount < this.maxCompletionRetries) {
|
|
1110
|
+
completionRetryCount++;
|
|
1111
|
+
const systemMessage = this.conversations.appendMessage(convId, 'system', COMPLETION_RETRY_PROMPT);
|
|
1112
|
+
this.emit({ type: 'message:system', message: systemMessage });
|
|
1113
|
+
this.emitCompletionTrace('retry_started', `Retrying attempt ${attemptNumber + 1} after incomplete completion (${completionRetryCount}/${this.maxCompletionRetries}).`, completionSignal, 'info');
|
|
1114
|
+
nextRetryCause = 'completion';
|
|
1115
|
+
continue;
|
|
725
1116
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
catch (error) {
|
|
740
|
-
const failureState = this.getStreamState();
|
|
741
|
-
this.stopStreamWatchdog();
|
|
742
|
-
// Check if this was a nudge-abort (not emergency stop, not a real error)
|
|
743
|
-
const isNudgeAbort = abortController.signal.aborted && this.streamNudgeCount > 0 && this.streamNudgeCount <= this.maxNudgeRetries;
|
|
744
|
-
if (isNudgeAbort) {
|
|
745
|
-
// Inject nudge message and retry via the for-loop
|
|
746
|
-
const systemMessage = this.conversations.appendMessage(convId, 'system', '[Cerebellum] You stopped mid-response. Continue from where you left off.');
|
|
747
|
-
this.emit({ type: 'message:system', message: systemMessage });
|
|
748
|
-
continue; // retry loop
|
|
1117
|
+
const diagnosticMessage = this.conversations.appendMessage(convId, 'system', '[Cerebellum] The turn ended repeatedly without a valid completion signal or final answer.');
|
|
1118
|
+
this.emit({ type: 'message:system', message: diagnosticMessage });
|
|
1119
|
+
this.emitCompletionTrace('retry_failed', `Completion retries exhausted after ${completionRetryCount}/${this.maxCompletionRetries}: ${completionFailure.message}`, completionSignal, 'error');
|
|
1120
|
+
this.emit({
|
|
1121
|
+
type: 'error',
|
|
1122
|
+
error: new Error('Turn ended without a valid completion signal or final answer.'),
|
|
1123
|
+
});
|
|
1124
|
+
loopTerminated = true;
|
|
1125
|
+
break;
|
|
1126
|
+
}
|
|
1127
|
+
loopTerminated = true;
|
|
1128
|
+
break; // success — exit retry loop
|
|
749
1129
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
const failureState = this.getStreamState();
|
|
1132
|
+
this.stopStreamWatchdog();
|
|
1133
|
+
// Check if this was a nudge-abort (not emergency stop, not a real error)
|
|
1134
|
+
const isNudgeAbort = abortController.signal.aborted
|
|
1135
|
+
&& this.streamNudgeCount > stallRetryCountAtStart
|
|
1136
|
+
&& this.streamNudgeCount <= this.maxNudgeRetries;
|
|
1137
|
+
if (isNudgeAbort) {
|
|
1138
|
+
// Inject nudge message and retry via the loop
|
|
1139
|
+
const systemMessage = this.conversations.appendMessage(convId, 'system', '[Cerebellum] You stopped mid-response. Continue from where you left off.');
|
|
1140
|
+
this.emit({ type: 'message:system', message: systemMessage });
|
|
1141
|
+
this.emitWatchdog('retry_started', `Retrying stalled turn with attempt ${attemptNumber + 1} (stall retry ${this.streamNudgeCount}/${this.maxNudgeRetries}).`, { level: 'info' });
|
|
1142
|
+
nextRetryCause = 'stall';
|
|
1143
|
+
continue; // retry loop
|
|
1144
|
+
}
|
|
1145
|
+
// Check if Cerebellum dropped mid-stream
|
|
1146
|
+
if (this.cerebellum && !this.cerebellum.isConnected() && abortController.signal.aborted) {
|
|
1147
|
+
const err = new Error('Cerebellum disconnected during active response. Restart it with: docker compose up -d cerebellum');
|
|
1148
|
+
log.error('Cerebellum disconnected mid-stream', { error: err.message });
|
|
1149
|
+
this.emit({ type: 'error', error: err });
|
|
1150
|
+
loopTerminated = true;
|
|
1151
|
+
break;
|
|
1152
|
+
}
|
|
1153
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1154
|
+
if (retryCause === 'completion') {
|
|
1155
|
+
this.emitCompletionTrace('retry_failed', `Completion retry attempt ${attemptNumber} failed: ${err.message}`, completionState.signal, 'error');
|
|
1156
|
+
}
|
|
1157
|
+
else if (retryCause === 'stall') {
|
|
1158
|
+
this.emitWatchdog('retry_failed', `Stall retry attempt ${attemptNumber} failed: ${err.message}`, { level: 'error' });
|
|
1159
|
+
}
|
|
1160
|
+
log.error('Send message failed', {
|
|
1161
|
+
error: err.message,
|
|
1162
|
+
turnId,
|
|
1163
|
+
attempt: attemptNumber,
|
|
1164
|
+
conversationId: convId,
|
|
1165
|
+
phase: failureState.phase,
|
|
1166
|
+
activeToolName: failureState.activeToolName,
|
|
1167
|
+
activeToolCallId: failureState.activeToolCallId,
|
|
1168
|
+
activeToolStartedAt: failureState.activeToolStartedAt,
|
|
1169
|
+
stallRetryCount: this.streamNudgeCount,
|
|
1170
|
+
completionRetryCount,
|
|
1171
|
+
retryCause,
|
|
1172
|
+
});
|
|
754
1173
|
this.emit({ type: 'error', error: err });
|
|
1174
|
+
loopTerminated = true;
|
|
755
1175
|
break;
|
|
756
1176
|
}
|
|
757
|
-
|
|
1177
|
+
finally {
|
|
1178
|
+
this.currentAttemptCompletionState = null;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
if (!loopTerminated) {
|
|
1182
|
+
const err = new Error(`Retry safety limit reached after ${maxTotalAttempts} attempts.`);
|
|
758
1183
|
log.error('Send message failed', {
|
|
759
1184
|
error: err.message,
|
|
760
|
-
|
|
1185
|
+
turnId,
|
|
761
1186
|
conversationId: convId,
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
activeToolCallId: failureState.activeToolCallId,
|
|
765
|
-
activeToolStartedAt: failureState.activeToolStartedAt,
|
|
1187
|
+
stallRetryCount: this.streamNudgeCount,
|
|
1188
|
+
completionRetryCount,
|
|
766
1189
|
});
|
|
767
1190
|
this.emit({ type: 'error', error: err });
|
|
768
1191
|
}
|
|
769
|
-
|
|
770
|
-
|
|
1192
|
+
}
|
|
1193
|
+
finally {
|
|
1194
|
+
this.currentStreamTurn = null;
|
|
1195
|
+
}
|
|
771
1196
|
}
|
|
772
1197
|
async start() {
|
|
773
1198
|
if (!this.activeConversationId) {
|