@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.
- package/LICENSE +201 -0
- package/README.md +25 -0
- package/dist/hooks.d.ts +345 -0
- package/dist/hooks.js +52 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/logging-hooks.d.ts +120 -0
- package/dist/logging-hooks.js +280 -0
- package/dist/logging-hooks.js.map +1 -0
- package/dist/metrics.d.ts +243 -0
- package/dist/metrics.js +241 -0
- package/dist/metrics.js.map +1 -0
- package/dist/orchestrator.d.ts +73 -0
- package/dist/orchestrator.js +450 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/playbook-engine.d.ts +140 -0
- package/dist/playbook-engine.js +390 -0
- package/dist/playbook-engine.js.map +1 -0
- package/dist/playbook-orchestrator.d.ts +193 -0
- package/dist/playbook-orchestrator.js +687 -0
- package/dist/playbook-orchestrator.js.map +1 -0
- package/dist/playbook.d.ts +220 -0
- package/dist/playbook.js +92 -0
- package/dist/playbook.js.map +1 -0
- package/dist/protocol.d.ts +327 -0
- package/dist/protocol.js +149 -0
- package/dist/protocol.js.map +1 -0
- package/dist/tool-executor.d.ts +64 -0
- package/dist/tool-executor.js +240 -0
- package/dist/tool-executor.js.map +1 -0
- package/dist/tools.d.ts +183 -0
- package/dist/tools.js +157 -0
- package/dist/tools.js.map +1 -0
- package/dist/types.d.ts +213 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +40 -0
|
@@ -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
|