@llmrtc/llmrtc-core 1.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.
@@ -0,0 +1,687 @@
1
+ /**
2
+ * Playbook Orchestrator
3
+ *
4
+ * Orchestrates turn execution with two-phase model:
5
+ * - Phase 1: Tool loop - LLM can call tools repeatedly until it decides to respond
6
+ * - Phase 2: Final answer - Generate the user-facing response
7
+ *
8
+ * Integrates with STT/TTS for voice-based interactions.
9
+ */
10
+ import { ToolExecutor } from './tool-executor.js';
11
+ import { PlaybookEngine } from './playbook-engine.js';
12
+ import { PLAYBOOK_TRANSITION_TOOL } from './playbook.js';
13
+ /**
14
+ * Playbook Orchestrator - Manages two-phase turn execution
15
+ */
16
+ export class PlaybookOrchestrator {
17
+ constructor(llmProvider, playbook, toolRegistry, options = {}) {
18
+ Object.defineProperty(this, "llmProvider", {
19
+ enumerable: true,
20
+ configurable: true,
21
+ writable: true,
22
+ value: void 0
23
+ });
24
+ Object.defineProperty(this, "engine", {
25
+ enumerable: true,
26
+ configurable: true,
27
+ writable: true,
28
+ value: void 0
29
+ });
30
+ Object.defineProperty(this, "toolRegistry", {
31
+ enumerable: true,
32
+ configurable: true,
33
+ writable: true,
34
+ value: void 0
35
+ });
36
+ Object.defineProperty(this, "toolExecutor", {
37
+ enumerable: true,
38
+ configurable: true,
39
+ writable: true,
40
+ value: void 0
41
+ });
42
+ Object.defineProperty(this, "options", {
43
+ enumerable: true,
44
+ configurable: true,
45
+ writable: true,
46
+ value: void 0
47
+ });
48
+ Object.defineProperty(this, "listeners", {
49
+ enumerable: true,
50
+ configurable: true,
51
+ writable: true,
52
+ value: new Set()
53
+ });
54
+ Object.defineProperty(this, "conversationHistory", {
55
+ enumerable: true,
56
+ configurable: true,
57
+ writable: true,
58
+ value: []
59
+ });
60
+ // Concurrency protection: serialize turn execution to prevent history corruption
61
+ Object.defineProperty(this, "turnLock", {
62
+ enumerable: true,
63
+ configurable: true,
64
+ writable: true,
65
+ value: Promise.resolve()
66
+ });
67
+ Object.defineProperty(this, "isExecutingTurn", {
68
+ enumerable: true,
69
+ configurable: true,
70
+ writable: true,
71
+ value: false
72
+ });
73
+ this.llmProvider = llmProvider;
74
+ this.engine = new PlaybookEngine(playbook, {
75
+ debug: options.debug,
76
+ logger: options.logger
77
+ });
78
+ this.toolRegistry = toolRegistry;
79
+ this.toolExecutor = new ToolExecutor(toolRegistry, {
80
+ timeout: 30000,
81
+ maxConcurrency: 5
82
+ });
83
+ this.options = {
84
+ maxToolCallsPerTurn: 10,
85
+ phase1TimeoutMs: 60000,
86
+ llmRetries: 3,
87
+ historyLimit: 50,
88
+ ...options
89
+ };
90
+ // Forward playbook engine events
91
+ this.engine.on(event => this.emit(event));
92
+ // Register playbook_transition tool handler
93
+ this.registerTransitionTool();
94
+ }
95
+ /**
96
+ * Register the built-in playbook_transition tool
97
+ */
98
+ registerTransitionTool() {
99
+ // Only register if not already registered
100
+ if (this.toolRegistry.get('playbook_transition'))
101
+ return;
102
+ this.toolRegistry.register({
103
+ definition: PLAYBOOK_TRANSITION_TOOL,
104
+ handler: async (params) => {
105
+ // The actual transition is handled in executeTurn after tool execution
106
+ // This handler just validates and returns confirmation
107
+ const playbook = this.engine.getPlaybook();
108
+ const targetExists = playbook.stages.some(s => s.id === params.targetStage);
109
+ if (!targetExists) {
110
+ return {
111
+ success: false,
112
+ error: `Stage '${params.targetStage}' not found in playbook`
113
+ };
114
+ }
115
+ return {
116
+ success: true,
117
+ targetStage: params.targetStage,
118
+ reason: params.reason,
119
+ message: `Transition to '${params.targetStage}' will be executed`
120
+ };
121
+ }
122
+ });
123
+ }
124
+ /**
125
+ * Get the playbook engine
126
+ */
127
+ getEngine() {
128
+ return this.engine;
129
+ }
130
+ /**
131
+ * Get current conversation history
132
+ */
133
+ getHistory() {
134
+ return [...this.conversationHistory];
135
+ }
136
+ /**
137
+ * Clear conversation history
138
+ */
139
+ clearHistory() {
140
+ this.conversationHistory = [];
141
+ }
142
+ /**
143
+ * Subscribe to orchestrator events
144
+ */
145
+ on(listener) {
146
+ this.listeners.add(listener);
147
+ return () => this.listeners.delete(listener);
148
+ }
149
+ /**
150
+ * Emit an event to all listeners
151
+ */
152
+ async emit(event) {
153
+ for (const listener of this.listeners) {
154
+ await listener(event);
155
+ }
156
+ }
157
+ /**
158
+ * Log a message
159
+ */
160
+ log(level, msg, ...args) {
161
+ if (this.options.logger) {
162
+ this.options.logger[level](msg, ...args);
163
+ }
164
+ else if (this.options.debug) {
165
+ console[level](`[PlaybookOrchestrator] ${msg}`, ...args);
166
+ }
167
+ }
168
+ /**
169
+ * Add message to history with automatic cleanup when limit exceeded.
170
+ * Uses smart trimming to preserve tool call/result pairs (required by OpenAI API).
171
+ */
172
+ pushHistory(message) {
173
+ this.conversationHistory.push(message);
174
+ const limit = this.options.historyLimit ?? 50;
175
+ if (this.conversationHistory.length > limit) {
176
+ const overflow = this.conversationHistory.length - limit;
177
+ let trimPoint = overflow;
178
+ // Find a safe trim point that doesn't split tool call/result pairs
179
+ // We scan forward from the naive trim point to find a safe boundary
180
+ while (trimPoint < this.conversationHistory.length) {
181
+ const msgAtTrim = this.conversationHistory[trimPoint];
182
+ // Can't trim here if it's a tool result (would orphan it)
183
+ if (msgAtTrim.role === 'tool') {
184
+ trimPoint++;
185
+ continue;
186
+ }
187
+ // Can't trim if the previous message is assistant with toolCalls
188
+ // (would orphan the following tool results)
189
+ if (trimPoint > 0) {
190
+ const prevMsg = this.conversationHistory[trimPoint - 1];
191
+ if (prevMsg.role === 'assistant' && prevMsg.toolCalls?.length) {
192
+ trimPoint++;
193
+ continue;
194
+ }
195
+ }
196
+ // Found a safe boundary
197
+ break;
198
+ }
199
+ // Only trim if we found a safe point within the history
200
+ if (trimPoint > 0 && trimPoint < this.conversationHistory.length) {
201
+ this.conversationHistory.splice(0, trimPoint);
202
+ this.log('debug', `Trimmed ${trimPoint} messages from history (limit: ${limit})`);
203
+ }
204
+ }
205
+ }
206
+ /**
207
+ * Check if an error is retryable.
208
+ * Non-retryable: client errors (400, 401, 403, 404) except rate limits.
209
+ * Retryable: rate limits (429), server errors (5xx), timeouts.
210
+ */
211
+ isRetryableError(error) {
212
+ const message = error.message.toLowerCase();
213
+ // Non-retryable: client errors (except rate limit)
214
+ if (message.includes('400') || message.includes('bad request'))
215
+ return false;
216
+ if (message.includes('401') || message.includes('unauthorized'))
217
+ return false;
218
+ if (message.includes('403') || message.includes('forbidden'))
219
+ return false;
220
+ if (message.includes('404') || message.includes('not found'))
221
+ return false;
222
+ // Retryable: rate limits, server errors, timeouts
223
+ if (message.includes('429') || message.includes('rate limit'))
224
+ return true;
225
+ if (message.includes('500') || message.includes('502') || message.includes('503') || message.includes('504'))
226
+ return true;
227
+ if (message.includes('timeout') || message.includes('etimedout') || message.includes('econnreset'))
228
+ return true;
229
+ // Default: retry unknown errors (could be transient network issues)
230
+ return true;
231
+ }
232
+ /**
233
+ * Call LLM with smart retry and exponential backoff.
234
+ * Only retries on retryable errors (rate limits, server errors, timeouts).
235
+ */
236
+ async callLLMWithRetry(request) {
237
+ const maxRetries = this.options.llmRetries ?? 3;
238
+ let lastError = new Error('No attempts made');
239
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
240
+ // Check for abort
241
+ if (this.options.abortSignal?.aborted) {
242
+ throw new Error('LLM call aborted');
243
+ }
244
+ try {
245
+ return await this.llmProvider.complete(request);
246
+ }
247
+ catch (error) {
248
+ lastError = error instanceof Error ? error : new Error(String(error));
249
+ this.log('warn', `LLM call failed (attempt ${attempt + 1}/${maxRetries}): ${lastError.message}`);
250
+ // Don't retry non-retryable errors
251
+ if (!this.isRetryableError(lastError)) {
252
+ this.log('error', `Non-retryable error, aborting retries`);
253
+ throw lastError;
254
+ }
255
+ // Don't retry on last attempt
256
+ if (attempt < maxRetries - 1) {
257
+ // Exponential backoff: 1s, 2s, 4s
258
+ const delay = Math.pow(2, attempt) * 1000;
259
+ this.log('debug', `Retrying in ${delay}ms...`);
260
+ await new Promise(resolve => setTimeout(resolve, delay));
261
+ }
262
+ }
263
+ }
264
+ this.log('error', `LLM call failed after ${maxRetries} attempts`);
265
+ throw lastError;
266
+ }
267
+ /**
268
+ * Build LLM request for current state
269
+ */
270
+ buildLLMRequest(additionalMessages) {
271
+ const systemPrompt = this.engine.getEffectiveSystemPrompt();
272
+ const tools = this.engine.getAvailableTools();
273
+ const llmConfig = this.engine.getEffectiveLLMConfig();
274
+ const messages = [
275
+ { role: 'system', content: systemPrompt },
276
+ ...this.conversationHistory,
277
+ ...(additionalMessages ?? [])
278
+ ];
279
+ return {
280
+ messages,
281
+ tools: tools.length > 0 ? tools : undefined,
282
+ toolChoice: this.engine.getCurrentStage().toolChoice,
283
+ config: {
284
+ temperature: llmConfig.temperature,
285
+ maxTokens: llmConfig.maxTokens,
286
+ topP: llmConfig.topP
287
+ }
288
+ };
289
+ }
290
+ /**
291
+ * Execute Phase 1: Tool loop
292
+ * LLM can call tools repeatedly until it decides to respond
293
+ */
294
+ async executePhase1(userMessage, attachments) {
295
+ await this.emit({ type: 'phase1_start' });
296
+ const allToolCalls = [];
297
+ const llmResponses = [];
298
+ let pendingTransition;
299
+ let iterationCount = 0;
300
+ const startTime = Date.now();
301
+ // Add user message to history (with optional attachments for vision)
302
+ const userMsg = { role: 'user', content: userMessage };
303
+ if (attachments && attachments.length > 0) {
304
+ userMsg.attachments = attachments;
305
+ }
306
+ this.pushHistory(userMsg);
307
+ while (iterationCount < this.options.maxToolCallsPerTurn) {
308
+ // Check timeout
309
+ if (Date.now() - startTime > this.options.phase1TimeoutMs) {
310
+ this.log('warn', 'Phase 1 timeout reached');
311
+ break;
312
+ }
313
+ // Check abort
314
+ if (this.options.abortSignal?.aborted) {
315
+ throw new Error('Execution aborted');
316
+ }
317
+ // Call LLM with retry
318
+ const request = this.buildLLMRequest();
319
+ const result = await this.callLLMWithRetry(request);
320
+ llmResponses.push(result);
321
+ // Check if LLM wants to use tools
322
+ if (result.stopReason === 'tool_use' && result.toolCalls?.length) {
323
+ // Valid tool_use with actual tool calls
324
+ // Add assistant message with ALL tool calls BEFORE executing them
325
+ // This is required by OpenAI API - tool messages must follow an assistant message with tool_calls
326
+ this.pushHistory({
327
+ role: 'assistant',
328
+ content: result.fullText || '',
329
+ toolCalls: result.toolCalls
330
+ });
331
+ // Execute tool calls and add tool result messages
332
+ for (const toolCall of result.toolCalls) {
333
+ await this.emit({ type: 'tool_call_start', call: toolCall });
334
+ // Check for transition tool
335
+ if (toolCall.name === 'playbook_transition') {
336
+ const args = toolCall.arguments;
337
+ pendingTransition = args;
338
+ // Create a success result for the transition tool
339
+ const transitionResult = {
340
+ callId: toolCall.callId,
341
+ toolName: toolCall.name,
342
+ success: true,
343
+ result: { message: `Transition to '${args.targetStage}' acknowledged` },
344
+ durationMs: 0
345
+ };
346
+ allToolCalls.push({ request: toolCall, result: transitionResult });
347
+ await this.emit({ type: 'tool_call_complete', call: toolCall, result: transitionResult });
348
+ // Add tool result to history
349
+ this.pushHistory({
350
+ role: 'tool',
351
+ content: JSON.stringify(transitionResult.result),
352
+ toolCallId: toolCall.callId,
353
+ toolName: toolCall.name
354
+ });
355
+ // If transition is pending, we can proceed to phase 2
356
+ // Don't continue the tool loop after transition request
357
+ break;
358
+ }
359
+ // Execute regular tool
360
+ const toolResult = await this.toolExecutor.executeSingle(toolCall, {
361
+ sessionId: this.engine.getState().currentStage.id,
362
+ turnId: `turn-${Date.now()}`,
363
+ metadata: this.engine.getState().conversationContext
364
+ });
365
+ allToolCalls.push({ request: toolCall, result: toolResult });
366
+ await this.emit({ type: 'tool_call_complete', call: toolCall, result: toolResult });
367
+ // Add tool result to history
368
+ this.pushHistory({
369
+ role: 'tool',
370
+ content: JSON.stringify(toolResult.result ?? toolResult.error),
371
+ toolCallId: toolCall.callId,
372
+ toolName: toolCall.name
373
+ });
374
+ }
375
+ // If transition is pending, exit loop
376
+ if (pendingTransition) {
377
+ break;
378
+ }
379
+ iterationCount++;
380
+ }
381
+ else if (result.stopReason === 'tool_use' && (!result.toolCalls || result.toolCalls.length === 0)) {
382
+ // Edge case: LLM indicated tool_use but provided no tool calls
383
+ // This can happen with some models/edge cases - treat as done
384
+ this.log('warn', 'LLM returned tool_use stop reason but no tool calls were provided');
385
+ await this.emit({ type: 'phase1_complete', toolCallCount: allToolCalls.length });
386
+ return {
387
+ toolCalls: allToolCalls,
388
+ llmResponses,
389
+ pendingTransition,
390
+ finalResponse: result.fullText
391
+ };
392
+ }
393
+ else {
394
+ // LLM is done with tools, has a final response
395
+ await this.emit({ type: 'phase1_complete', toolCallCount: allToolCalls.length });
396
+ return {
397
+ toolCalls: allToolCalls,
398
+ llmResponses,
399
+ pendingTransition,
400
+ finalResponse: result.fullText
401
+ };
402
+ }
403
+ }
404
+ await this.emit({ type: 'phase1_complete', toolCallCount: allToolCalls.length });
405
+ return {
406
+ toolCalls: allToolCalls,
407
+ llmResponses,
408
+ pendingTransition
409
+ };
410
+ }
411
+ /**
412
+ * Execute Phase 2: Generate final response
413
+ */
414
+ async executePhase2() {
415
+ await this.emit({ type: 'phase2_start' });
416
+ // Build request for final response (no tools)
417
+ const systemPrompt = this.engine.getEffectiveSystemPrompt();
418
+ const llmConfig = this.engine.getEffectiveLLMConfig();
419
+ const request = {
420
+ messages: [
421
+ { role: 'system', content: systemPrompt + '\n\nNow provide your final response to the user based on the conversation and any tool results.' },
422
+ ...this.conversationHistory
423
+ ],
424
+ config: {
425
+ temperature: llmConfig.temperature,
426
+ maxTokens: llmConfig.maxTokens,
427
+ topP: llmConfig.topP
428
+ }
429
+ };
430
+ const result = await this.callLLMWithRetry(request);
431
+ const response = result.fullText;
432
+ // Add assistant response to history
433
+ this.pushHistory({ role: 'assistant', content: response });
434
+ await this.emit({ type: 'phase2_complete', response });
435
+ return response;
436
+ }
437
+ /**
438
+ * Execute a complete turn with user input.
439
+ * This method is serialized - concurrent calls will be queued.
440
+ * @param userMessage - The user's message text
441
+ * @param attachments - Optional vision attachments (images)
442
+ */
443
+ async executeTurn(userMessage, attachments) {
444
+ // Wait for any pending turn to complete
445
+ await this.turnLock;
446
+ // Create new lock for this turn
447
+ let releaseLock;
448
+ this.turnLock = new Promise(resolve => { releaseLock = resolve; });
449
+ this.isExecutingTurn = true;
450
+ try {
451
+ return await this._executeTurnInternal(userMessage, attachments);
452
+ }
453
+ finally {
454
+ this.isExecutingTurn = false;
455
+ releaseLock();
456
+ }
457
+ }
458
+ /**
459
+ * Internal turn execution logic
460
+ */
461
+ async _executeTurnInternal(userMessage, attachments) {
462
+ const stage = this.engine.getCurrentStage();
463
+ const useTwoPhase = stage.twoPhaseExecution !== false;
464
+ this.log('info', `Executing turn in stage '${stage.id}' (two-phase: ${useTwoPhase})`);
465
+ // Phase 1: Tool loop
466
+ const phase1Result = await this.executePhase1(userMessage, attachments);
467
+ let response;
468
+ let transitioned = false;
469
+ let transition;
470
+ let newStage;
471
+ // Handle pending transition
472
+ if (phase1Result.pendingTransition) {
473
+ const evalResult = await this.engine.evaluateExplicitTransition(phase1Result.pendingTransition.targetStage, phase1Result.pendingTransition.reason, phase1Result.pendingTransition.data);
474
+ if (evalResult.shouldTransition && evalResult.transition) {
475
+ transition = evalResult.transition;
476
+ await this.emit({ type: 'transition_triggered', transition });
477
+ await this.engine.executeTransition(transition, phase1Result.pendingTransition.data);
478
+ transitioned = true;
479
+ newStage = this.engine.getCurrentStage();
480
+ }
481
+ }
482
+ // Check for automatic transitions based on tool calls
483
+ if (!transitioned && phase1Result.toolCalls.length > 0) {
484
+ const toolCallsForEval = phase1Result.toolCalls.map(tc => ({
485
+ name: tc.request.name,
486
+ arguments: tc.request.arguments
487
+ }));
488
+ const lastResponse = phase1Result.llmResponses[phase1Result.llmResponses.length - 1];
489
+ const evalResult = await this.engine.evaluateTransitions(lastResponse?.fullText, toolCallsForEval);
490
+ if (evalResult.shouldTransition && evalResult.transition) {
491
+ transition = evalResult.transition;
492
+ await this.emit({ type: 'transition_triggered', transition });
493
+ await this.engine.executeTransition(transition);
494
+ transitioned = true;
495
+ newStage = this.engine.getCurrentStage();
496
+ }
497
+ }
498
+ // Phase 2: Final response (if using two-phase and no final response from phase 1)
499
+ if (useTwoPhase && !phase1Result.finalResponse) {
500
+ response = await this.executePhase2();
501
+ }
502
+ else {
503
+ response = phase1Result.finalResponse ?? '';
504
+ // Add to history if not already added
505
+ if (!this.conversationHistory.some(m => m.role === 'assistant' && m.content === response)) {
506
+ this.pushHistory({ role: 'assistant', content: response });
507
+ }
508
+ }
509
+ // Complete turn
510
+ await this.engine.completeTurn();
511
+ // Check for automatic transitions based on final response
512
+ if (!transitioned) {
513
+ const evalResult = await this.engine.evaluateTransitions(response);
514
+ if (evalResult.shouldTransition && evalResult.transition) {
515
+ transition = evalResult.transition;
516
+ await this.emit({ type: 'transition_triggered', transition });
517
+ await this.engine.executeTransition(transition);
518
+ transitioned = true;
519
+ newStage = this.engine.getCurrentStage();
520
+ }
521
+ }
522
+ const lastLLMResponse = phase1Result.llmResponses[phase1Result.llmResponses.length - 1];
523
+ return {
524
+ response,
525
+ toolCalls: phase1Result.toolCalls,
526
+ transitioned,
527
+ transition,
528
+ newStage,
529
+ llmResponses: phase1Result.llmResponses,
530
+ stopReason: lastLLMResponse?.stopReason
531
+ };
532
+ }
533
+ /**
534
+ * Execute a turn with streaming response.
535
+ * This method is serialized - concurrent calls will be queued.
536
+ * @param userMessage - The user's message text
537
+ * @param attachments - Optional vision attachments (images)
538
+ */
539
+ async *streamTurn(userMessage, attachments) {
540
+ // Wait for any pending turn to complete
541
+ await this.turnLock;
542
+ // Create new lock for this turn
543
+ let releaseLock;
544
+ this.turnLock = new Promise(resolve => { releaseLock = resolve; });
545
+ this.isExecutingTurn = true;
546
+ try {
547
+ // Yield from internal implementation
548
+ yield* this._streamTurnInternal(userMessage, attachments);
549
+ }
550
+ finally {
551
+ this.isExecutingTurn = false;
552
+ releaseLock();
553
+ }
554
+ }
555
+ /**
556
+ * Internal streaming turn logic
557
+ */
558
+ async *_streamTurnInternal(userMessage, attachments) {
559
+ // For streaming, we run phase 1 first, then stream phase 2
560
+ const phase1Result = await this.executePhase1(userMessage, attachments);
561
+ // Yield tool calls
562
+ for (const tc of phase1Result.toolCalls) {
563
+ yield { type: 'tool_call', data: tc.request };
564
+ }
565
+ // Track full response for transition evaluation
566
+ let fullResponse = '';
567
+ // If we have a final response from phase 1, yield it
568
+ if (phase1Result.finalResponse) {
569
+ fullResponse = phase1Result.finalResponse;
570
+ yield { type: 'content', data: phase1Result.finalResponse };
571
+ this.pushHistory({ role: 'assistant', content: phase1Result.finalResponse });
572
+ }
573
+ else {
574
+ // Stream phase 2
575
+ const systemPrompt = this.engine.getEffectiveSystemPrompt();
576
+ const llmConfig = this.engine.getEffectiveLLMConfig();
577
+ const request = {
578
+ messages: [
579
+ { role: 'system', content: systemPrompt + '\n\nNow provide your final response to the user.' },
580
+ ...this.conversationHistory
581
+ ],
582
+ config: {
583
+ temperature: llmConfig.temperature,
584
+ maxTokens: llmConfig.maxTokens,
585
+ topP: llmConfig.topP
586
+ }
587
+ };
588
+ if (this.llmProvider.stream) {
589
+ for await (const chunk of this.llmProvider.stream(request)) {
590
+ if (chunk.content) {
591
+ yield { type: 'content', data: chunk.content };
592
+ fullResponse += chunk.content;
593
+ }
594
+ }
595
+ }
596
+ else {
597
+ // Fall back to complete() with retry if stream is not available
598
+ const result = await this.callLLMWithRetry(request);
599
+ fullResponse = result.fullText;
600
+ yield { type: 'content', data: fullResponse };
601
+ }
602
+ this.pushHistory({ role: 'assistant', content: fullResponse });
603
+ }
604
+ // Handle transitions
605
+ let transitioned = false;
606
+ let transition;
607
+ let newStage;
608
+ // 1. Handle pending transition from playbook_transition tool
609
+ if (phase1Result.pendingTransition) {
610
+ const evalResult = await this.engine.evaluateExplicitTransition(phase1Result.pendingTransition.targetStage, phase1Result.pendingTransition.reason, phase1Result.pendingTransition.data);
611
+ if (evalResult.shouldTransition && evalResult.transition) {
612
+ transition = evalResult.transition;
613
+ await this.emit({ type: 'transition_triggered', transition });
614
+ await this.engine.executeTransition(transition, phase1Result.pendingTransition.data);
615
+ transitioned = true;
616
+ newStage = this.engine.getCurrentStage();
617
+ }
618
+ }
619
+ // 2. Check for automatic transitions based on tool calls
620
+ if (!transitioned && phase1Result.toolCalls.length > 0) {
621
+ const toolCallsForEval = phase1Result.toolCalls.map(tc => ({
622
+ name: tc.request.name,
623
+ arguments: tc.request.arguments
624
+ }));
625
+ const lastResponse = phase1Result.llmResponses[phase1Result.llmResponses.length - 1];
626
+ const evalResult = await this.engine.evaluateTransitions(lastResponse?.fullText, toolCallsForEval);
627
+ if (evalResult.shouldTransition && evalResult.transition) {
628
+ transition = evalResult.transition;
629
+ await this.emit({ type: 'transition_triggered', transition });
630
+ await this.engine.executeTransition(transition);
631
+ transitioned = true;
632
+ newStage = this.engine.getCurrentStage();
633
+ }
634
+ }
635
+ // Complete turn
636
+ await this.engine.completeTurn();
637
+ // 3. Check for automatic transitions based on final response (keyword, etc.)
638
+ if (!transitioned) {
639
+ const evalResult = await this.engine.evaluateTransitions(fullResponse);
640
+ if (evalResult.shouldTransition && evalResult.transition) {
641
+ transition = evalResult.transition;
642
+ await this.emit({ type: 'transition_triggered', transition });
643
+ await this.engine.executeTransition(transition);
644
+ transitioned = true;
645
+ newStage = this.engine.getCurrentStage();
646
+ }
647
+ }
648
+ const lastMessage = this.conversationHistory[this.conversationHistory.length - 1];
649
+ yield {
650
+ type: 'done',
651
+ data: {
652
+ response: lastMessage?.content ?? '',
653
+ toolCalls: phase1Result.toolCalls,
654
+ transitioned,
655
+ transition,
656
+ newStage,
657
+ llmResponses: phase1Result.llmResponses,
658
+ stopReason: phase1Result.llmResponses[phase1Result.llmResponses.length - 1]?.stopReason
659
+ }
660
+ };
661
+ }
662
+ /**
663
+ * Reset orchestrator to initial state
664
+ */
665
+ reset() {
666
+ this.engine.reset();
667
+ this.conversationHistory = [];
668
+ }
669
+ }
670
+ /**
671
+ * Create a simple playbook with a single stage
672
+ */
673
+ export function createSimplePlaybook(id, systemPrompt, tools) {
674
+ return {
675
+ id,
676
+ name: id,
677
+ stages: [{
678
+ id: 'main',
679
+ name: 'Main',
680
+ systemPrompt,
681
+ tools
682
+ }],
683
+ transitions: [],
684
+ initialStage: 'main'
685
+ };
686
+ }
687
+ //# sourceMappingURL=playbook-orchestrator.js.map