@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.
@@ -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.tools.set(name, tool);
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.tools,
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: toolCall.name.trim() || toolCall.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
- log.warn('Cerebrum stream stalled', diagnostics);
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
- log.warn('Cerebellum disconnected during active stream aborting');
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
- log.info('Cerebellum nudging stalled stream', { attempt: this.streamNudgeCount, ...diagnostics });
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
- // Retry loop — nudge aborts land here for retry
553
- for (let attempt = 0; attempt <= this.maxNudgeRetries; attempt++) {
554
- const abortController = new AbortController();
555
- const isCurrentAttempt = () => this.abortController === abortController;
556
- this.abortController = abortController;
557
- if (attempt > 0) {
558
- log.info('Retrying Cerebrum stream after watchdog nudge', {
559
- attempt,
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
- this.emit({ type: 'message:cerebrum:start', conversationId: convId });
564
- this.startStreamWatchdog();
565
- let messages = this.conversations.getMessages(convId);
566
- // Context window compaction
567
- if (this.compactionConfig.enabled &&
568
- this.cerebrum?.summarize &&
569
- shouldCompact(messages, this.compactionConfig.contextWindow, this.compactionConfig.threshold)) {
570
- try {
571
- const keepRecent = this.compactionConfig.keepRecentMessages;
572
- const olderMessages = messages.slice(0, Math.max(0, messages.length - keepRecent));
573
- if (olderMessages.length > 0) {
574
- log.info('Compacting conversation', {
575
- totalMessages: messages.length,
576
- compactingMessages: olderMessages.length,
577
- estimatedTokens: estimateMessageTokens(messages),
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
- catch (error) {
584
- log.warn('Compaction failed, continuing with full context', {
585
- error: error instanceof Error ? error.message : String(error),
586
- });
587
- }
588
- }
589
- // Build system prompt with runtime state + skills context
590
- const instance = this.instanceStore?.get();
591
- const basePrompt = buildSystemPrompt({
592
- cerebellumConnected: this.cerebellum?.isConnected() ?? false,
593
- tools: this.tools,
594
- autoMode: this.autoMode,
595
- gatewayMode: this.gatewayMode,
596
- connectedNodes: this.connectedNodes,
597
- gatewayUrl: this.gatewayUrl,
598
- profile: this.profile,
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
- onToolCall: async (toolCall) => {
638
- throwIfToolAttemptAborted();
639
- this.markStreamWaitingTool(toolCall);
640
- const requestedToolName = toolCall.name;
641
- const normalizedToolName = requestedToolName.trim() || requestedToolName;
642
- this.emit({ type: 'message:cerebrum:toolcall', toolCall: { ...toolCall, name: normalizedToolName } });
643
- this.emit({ type: 'tool:start', callId: toolCall.id, name: normalizedToolName });
644
- const { toolName, result } = await this.toolRuntime.execute({
645
- toolCall,
646
- tools: this.tools,
647
- conversationId: convId,
648
- sessionKey: 'agent:main',
649
- scopeKey: convId,
650
- abortSignal: abortController.signal,
651
- });
652
- throwIfAborted(abortController.signal, 'Tool execution aborted');
653
- this.markStreamWaitingModel();
654
- this.emit({ type: 'tool:end', result });
655
- // Cerebellum verification (non-blocking)
656
- if (this.cerebellum?.isConnected() && this.verificationEnabled) {
657
- try {
658
- throwIfAborted(abortController.signal, 'Tool execution aborted');
659
- this.emit({ type: 'verification:start', callId: toolCall.id, toolName });
660
- const toolArgs = {};
661
- for (const [k, v] of Object.entries(toolCall.args)) {
662
- toolArgs[k] = String(v);
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
- const verifyPromise = this.cerebellum.verifyToolResult(toolName, toolArgs, result.output, !result.isError);
665
- const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), this.verificationTimeoutMs));
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
- if (verification) {
676
- const vResult = {
677
- passed: verification.passed,
678
- checks: verification.checks,
679
- modelVerdict: verification.modelVerdict,
680
- toolCallId: toolCall.id,
1015
+ }
1016
+ throwIfToolAttemptAborted();
1017
+ if (!isInternalTaskSignal) {
1018
+ this.conversations.appendMessage(convId, 'tool', result.output, {
1019
+ toolResult: result,
1020
+ metadata: {
681
1021
  toolName,
682
- };
683
- this.emit({ type: 'verification:end', result: vResult });
684
- }
1022
+ ...(requestedToolName !== toolName ? { requestedToolName } : {}),
1023
+ },
1024
+ });
685
1025
  }
686
- catch {
687
- // Verification failure should never block tool execution
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
- throwIfToolAttemptAborted();
691
- this.conversations.appendMessage(convId, 'tool', result.output, {
692
- toolResult: result,
693
- metadata: {
694
- toolName,
695
- ...(requestedToolName !== toolName ? { requestedToolName } : {}),
696
- },
697
- });
698
- return result;
699
- },
700
- onFinish: (content, toolCalls) => {
701
- if (!isCurrentAttempt() || abortController.signal.aborted)
702
- return;
703
- this.stopStreamWatchdog();
704
- let displayContent = content;
705
- // Check for discovery completion — parse and strip the tag before storing
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
- const cerebrumMessage = this.conversations.appendMessage(convId, 'cerebrum', displayContent, toolCalls?.length ? { toolCalls } : undefined);
719
- this.emit({ type: 'message:cerebrum:end', message: cerebrumMessage });
720
- if (attempt > 0) {
721
- log.info('Cerebrum stream recovered after watchdog retry', {
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
- onError: (error) => {
728
- if (!isCurrentAttempt())
729
- return;
730
- this.stopStreamWatchdog();
731
- // Don't log/emit if the abort was intentional (nudge or Cerebellum disconnect) — catch block handles it
732
- if (abortController.signal.aborted)
733
- return;
734
- log.error('Cerebrum stream error', { error: error.message });
735
- this.emit({ type: 'error', error });
736
- },
737
- }, { abortSignal: abortController.signal });
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
- // Check if Cerebellum dropped mid-stream
751
- if (this.cerebellum && !this.cerebellum.isConnected() && abortController.signal.aborted) {
752
- const err = new Error('Cerebellum disconnected during active response. Restart it with: docker compose up -d cerebellum');
753
- log.error('Cerebellum disconnected mid-stream', { error: err.message });
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
- const err = error instanceof Error ? error : new Error(String(error));
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
- attempt,
1185
+ turnId,
761
1186
  conversationId: convId,
762
- phase: failureState.phase,
763
- activeToolName: failureState.activeToolName,
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
- break; // success — exit retry loop
770
- } // end retry for-loop
1192
+ }
1193
+ finally {
1194
+ this.currentStreamTurn = null;
1195
+ }
771
1196
  }
772
1197
  async start() {
773
1198
  if (!this.activeConversationId) {