@bliggy/chat-runtime 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +314 -2
- package/index.js +35 -0
- package/package.json +29 -4
- package/src/channels.js +72 -0
- package/src/conversation.js +294 -0
- package/src/memoryTurnStore.js +95 -0
- package/src/pendingActions.js +37 -0
- package/src/runtime.js +525 -0
- package/src/twilio.js +176 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The model client owns wire formats, retries and streaming. This module owns
|
|
4
|
+
// only the bounded conversation loop; applications still authorize tools and
|
|
5
|
+
// persist their messages, actions and receipts.
|
|
6
|
+
function defaultStreamTurn(options) {
|
|
7
|
+
return require('@bliggy/llm-client').streamChatTurn(options);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PRIVATE_BLOCK_TYPES = new Set(['reasoning', 'thinking', 'redacted_thinking']);
|
|
11
|
+
const COMPLETED_STOP_REASONS = new Set(['end_turn', 'stop_sequence', 'refusal']);
|
|
12
|
+
|
|
13
|
+
function responseStateError(code, message, stopReason) {
|
|
14
|
+
const error = new Error(message);
|
|
15
|
+
error.code = code;
|
|
16
|
+
error.stopReason = stopReason ?? null;
|
|
17
|
+
return error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cancellationError() {
|
|
21
|
+
const error = new Error('Conversation cancelled.');
|
|
22
|
+
error.name = 'AbortError';
|
|
23
|
+
error.code = 'CHAT_CONVERSATION_CANCELLED';
|
|
24
|
+
return error;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertActive(signal) {
|
|
28
|
+
if (signal?.aborted) throw cancellationError();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toolResultBlock(toolCall, outcome, executor) {
|
|
32
|
+
if (!outcome || outcome.output === undefined) {
|
|
33
|
+
throw new TypeError(`${executor} must return an outcome with output.`);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
type: 'tool_result',
|
|
37
|
+
toolCallId: toolCall.id,
|
|
38
|
+
output: outcome.output,
|
|
39
|
+
...(outcome.isError !== undefined || executor === 'executeTool'
|
|
40
|
+
? { isError: Boolean(outcome.isError) }
|
|
41
|
+
: {})
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function consumeTurn(streamTurn, request, onEvent, signal) {
|
|
46
|
+
assertActive(signal);
|
|
47
|
+
let result = null;
|
|
48
|
+
for await (const event of streamTurn(request)) {
|
|
49
|
+
assertActive(signal);
|
|
50
|
+
if (event.type === 'done') {
|
|
51
|
+
if (result) throw new Error('Chat stream returned more than one final result.');
|
|
52
|
+
result = event.result;
|
|
53
|
+
} else {
|
|
54
|
+
await onEvent(event);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
assertActive(signal);
|
|
58
|
+
if (!result) throw new Error('Chat stream ended without a final result.');
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function runConversation({
|
|
63
|
+
messages: initialMessages,
|
|
64
|
+
tools = [],
|
|
65
|
+
maxIterations = 12,
|
|
66
|
+
requestOptions = {},
|
|
67
|
+
streamTurn = defaultStreamTurn,
|
|
68
|
+
getSystem = null,
|
|
69
|
+
onEvent = () => {},
|
|
70
|
+
trackTurn = (run) => run(),
|
|
71
|
+
beforeResponse = () => null,
|
|
72
|
+
onAssistantMessage = () => {},
|
|
73
|
+
executeTool = null,
|
|
74
|
+
executeTools = null,
|
|
75
|
+
onToolResult = () => {},
|
|
76
|
+
signal = null
|
|
77
|
+
} = {}) {
|
|
78
|
+
if (!Array.isArray(initialMessages) || initialMessages.length === 0) {
|
|
79
|
+
throw new TypeError('messages must be a non-empty array.');
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(tools)) throw new TypeError('tools must be an array.');
|
|
82
|
+
if (!Number.isInteger(maxIterations) || maxIterations < 1) {
|
|
83
|
+
throw new TypeError('maxIterations must be a positive integer.');
|
|
84
|
+
}
|
|
85
|
+
for (const [name, callback] of Object.entries({
|
|
86
|
+
streamTurn, onEvent, trackTurn, beforeResponse, onAssistantMessage, onToolResult
|
|
87
|
+
})) {
|
|
88
|
+
if (typeof callback !== 'function') throw new TypeError(`${name} must be a function.`);
|
|
89
|
+
}
|
|
90
|
+
for (const [name, callback] of Object.entries({ getSystem, executeTool, executeTools })) {
|
|
91
|
+
if (callback !== null && typeof callback !== 'function') throw new TypeError(`${name} must be a function.`);
|
|
92
|
+
}
|
|
93
|
+
if (executeTool && executeTools) throw new TypeError('executeTool and executeTools are mutually exclusive.');
|
|
94
|
+
|
|
95
|
+
const emit = async (event) => {
|
|
96
|
+
assertActive(signal);
|
|
97
|
+
await onEvent(event);
|
|
98
|
+
assertActive(signal);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const messages = [...initialMessages];
|
|
102
|
+
let forceText = false;
|
|
103
|
+
let result = null;
|
|
104
|
+
|
|
105
|
+
for (let iteration = 1; iteration <= maxIterations; iteration += 1) {
|
|
106
|
+
assertActive(signal);
|
|
107
|
+
const textOnly = forceText || iteration === maxIterations || tools.length === 0;
|
|
108
|
+
const state = { iteration, textOnly, messages, emit };
|
|
109
|
+
const request = {
|
|
110
|
+
...requestOptions,
|
|
111
|
+
system: getSystem ? await getSystem(state) : requestOptions.system,
|
|
112
|
+
messages: [...messages],
|
|
113
|
+
tools,
|
|
114
|
+
toolChoice: textOnly ? 'none' : 'auto',
|
|
115
|
+
parallelToolCalls: false
|
|
116
|
+
};
|
|
117
|
+
assertActive(signal);
|
|
118
|
+
result = await trackTurn(() => consumeTurn(streamTurn, request, emit, signal), state);
|
|
119
|
+
assertActive(signal);
|
|
120
|
+
if (!result || result.message?.role !== 'assistant' || !Array.isArray(result.message.content)) {
|
|
121
|
+
throw new TypeError('Chat result must contain a normalized assistant message.');
|
|
122
|
+
}
|
|
123
|
+
const finish = (status) => ({ status, result, messages, iterations: iteration });
|
|
124
|
+
// An output limit may leave a partial tool call. Let the host render its
|
|
125
|
+
// own notice without persisting or executing that incomplete response.
|
|
126
|
+
if (result.stopReason === 'max_tokens') return finish('max_tokens');
|
|
127
|
+
if (result.toolError) throw result.toolError;
|
|
128
|
+
|
|
129
|
+
const assistantMessage = result.message;
|
|
130
|
+
const toolCalls = assistantMessage.content.filter(block => block.type === 'tool_call');
|
|
131
|
+
if (result.stopReason !== 'tool_call' && !COMPLETED_STOP_REASONS.has(result.stopReason)) {
|
|
132
|
+
throw responseStateError(
|
|
133
|
+
'CHAT_RESPONSE_NOT_COMPLETE',
|
|
134
|
+
'Chat response did not reach a supported completion state.',
|
|
135
|
+
result.stopReason
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if ((result.stopReason === 'tool_call') !== (toolCalls.length > 0)) {
|
|
139
|
+
throw responseStateError(
|
|
140
|
+
'INVALID_CHAT_TOOL_STATE',
|
|
141
|
+
'Chat response stop reason does not match its tool calls.',
|
|
142
|
+
result.stopReason
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const responseState = { ...state, result, assistantMessage, toolCalls };
|
|
146
|
+
// The final request is text only. A provider disregarding that request must
|
|
147
|
+
// never cause one more action after the host's execution budget is spent.
|
|
148
|
+
if (textOnly && toolCalls.length) return finish('limit');
|
|
149
|
+
|
|
150
|
+
const policy = await beforeResponse(responseState);
|
|
151
|
+
assertActive(signal);
|
|
152
|
+
if (policy?.retryMessages !== undefined) {
|
|
153
|
+
if (!Array.isArray(policy.retryMessages) || policy.retryMessages.length === 0) {
|
|
154
|
+
throw new TypeError('beforeResponse.retryMessages must be a non-empty array.');
|
|
155
|
+
}
|
|
156
|
+
messages.push(assistantMessage, ...policy.retryMessages);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (toolCalls.length && !executeTool && !executeTools) {
|
|
160
|
+
throw new TypeError('executeTool or executeTools is required for tool calls.');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const persistedMessage = {
|
|
164
|
+
...assistantMessage,
|
|
165
|
+
content: assistantMessage.content.filter(block => !PRIVATE_BLOCK_TYPES.has(block.type))
|
|
166
|
+
};
|
|
167
|
+
await onAssistantMessage(persistedMessage, responseState);
|
|
168
|
+
assertActive(signal);
|
|
169
|
+
// Keep the complete response, including opaque provider reasoning, in the
|
|
170
|
+
// in-memory replay. Only the persistence callback receives filtered content.
|
|
171
|
+
messages.push(assistantMessage);
|
|
172
|
+
if (toolCalls.length === 0) return finish('completed');
|
|
173
|
+
|
|
174
|
+
forceText = false;
|
|
175
|
+
if (executeTools) {
|
|
176
|
+
const outcomes = await executeTools(toolCalls, responseState);
|
|
177
|
+
assertActive(signal);
|
|
178
|
+
if (!Array.isArray(outcomes) || outcomes.length !== toolCalls.length) {
|
|
179
|
+
throw new TypeError('executeTools must return one outcome per tool in the original order.');
|
|
180
|
+
}
|
|
181
|
+
const message = {
|
|
182
|
+
role: 'user',
|
|
183
|
+
content: toolCalls.map((toolCall, index) => toolResultBlock(toolCall, outcomes[index], 'executeTools'))
|
|
184
|
+
};
|
|
185
|
+
await onToolResult(message, { ...responseState, outcomes });
|
|
186
|
+
assertActive(signal);
|
|
187
|
+
messages.push(message);
|
|
188
|
+
forceText = outcomes.some(outcome => Boolean(outcome.forceText));
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const toolCall of toolCalls) {
|
|
193
|
+
assertActive(signal);
|
|
194
|
+
const outcome = await executeTool(toolCall, responseState);
|
|
195
|
+
assertActive(signal);
|
|
196
|
+
const message = {
|
|
197
|
+
role: 'user',
|
|
198
|
+
content: [toolResultBlock(toolCall, outcome, 'executeTool')]
|
|
199
|
+
};
|
|
200
|
+
await onToolResult(message, { ...responseState, toolCall, outcome });
|
|
201
|
+
assertActive(signal);
|
|
202
|
+
messages.push(message);
|
|
203
|
+
forceText ||= Boolean(outcome.forceText);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { status: 'limit', result, messages, iterations: maxIterations };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// One engine, two consumption styles. Each emitted event waits until the caller
|
|
211
|
+
// advances the iterator, so returning at a text/tool event cannot leave the loop
|
|
212
|
+
// running ahead into later actions. A host action already started is not undone.
|
|
213
|
+
async function* conversationEvents(options, controller) {
|
|
214
|
+
const signal = options.signal
|
|
215
|
+
? AbortSignal.any([options.signal, controller.signal])
|
|
216
|
+
: controller.signal;
|
|
217
|
+
assertActive(signal);
|
|
218
|
+
const queue = [];
|
|
219
|
+
const pending = new Set();
|
|
220
|
+
let wake = null;
|
|
221
|
+
let closed = false;
|
|
222
|
+
|
|
223
|
+
function deliver(item) {
|
|
224
|
+
if (closed) return;
|
|
225
|
+
queue.push(item);
|
|
226
|
+
if (wake) {
|
|
227
|
+
const resume = wake;
|
|
228
|
+
wake = null;
|
|
229
|
+
resume();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function abort() {
|
|
234
|
+
const error = cancellationError();
|
|
235
|
+
for (const acknowledgment of pending) acknowledgment.reject(error);
|
|
236
|
+
pending.clear();
|
|
237
|
+
queue.length = 0;
|
|
238
|
+
deliver({ error });
|
|
239
|
+
}
|
|
240
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
241
|
+
|
|
242
|
+
const onEvent = async (event) => {
|
|
243
|
+
await options.onEvent?.(event);
|
|
244
|
+
assertActive(signal);
|
|
245
|
+
await new Promise((resolve, reject) => {
|
|
246
|
+
const acknowledgment = { resolve, reject };
|
|
247
|
+
pending.add(acknowledgment);
|
|
248
|
+
deliver({ event, acknowledgment });
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
// Both handlers consume settlement, including after a caller closes the
|
|
252
|
+
// iterator while a provider or host callback is still finishing.
|
|
253
|
+
runConversation({ ...options, signal, onEvent }).then(
|
|
254
|
+
result => deliver({ event: { type: 'done', result }, final: true }),
|
|
255
|
+
error => deliver({ error })
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
while (true) {
|
|
260
|
+
if (queue.length === 0) await new Promise(resolve => { wake = resolve; });
|
|
261
|
+
const item = queue.shift();
|
|
262
|
+
if (item.error) throw item.error;
|
|
263
|
+
yield item.event;
|
|
264
|
+
if (item.acknowledgment) {
|
|
265
|
+
pending.delete(item.acknowledgment);
|
|
266
|
+
item.acknowledgment.resolve();
|
|
267
|
+
}
|
|
268
|
+
if (item.final) return;
|
|
269
|
+
}
|
|
270
|
+
} finally {
|
|
271
|
+
closed = true;
|
|
272
|
+
controller.abort();
|
|
273
|
+
signal.removeEventListener('abort', abort);
|
|
274
|
+
queue.length = 0;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function streamConversation(options = {}) {
|
|
279
|
+
const controller = new AbortController();
|
|
280
|
+
const iterator = conversationEvents(options, controller);
|
|
281
|
+
// Native async-generator return/throw waits behind a pending next(). Signal
|
|
282
|
+
// cancellation immediately so an in-flight response cannot start a tool while
|
|
283
|
+
// the caller is waiting for that queued return to reach the generator.
|
|
284
|
+
for (const method of ['return', 'throw']) {
|
|
285
|
+
const close = iterator[method].bind(iterator);
|
|
286
|
+
iterator[method] = (...args) => {
|
|
287
|
+
controller.abort();
|
|
288
|
+
return close(...args);
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
return iterator;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = { runConversation, streamConversation };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
5
|
+
const { TURN_TRANSITIONS } = require('./runtime');
|
|
6
|
+
|
|
7
|
+
function clone(value) {
|
|
8
|
+
return structuredClone(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class MemoryTurnStore {
|
|
12
|
+
constructor({ now = () => new Date() } = {}) {
|
|
13
|
+
this.now = now;
|
|
14
|
+
this.turns = new Map();
|
|
15
|
+
this.sourceIndex = new Map();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async claim(attributes) {
|
|
19
|
+
const sourceIdentity = [attributes.tenantKey, attributes.channel, attributes.sourceKey].join(':');
|
|
20
|
+
const existingId = this.sourceIndex.get(sourceIdentity);
|
|
21
|
+
if (existingId) return { turn: clone(this.turns.get(existingId)), created: false };
|
|
22
|
+
|
|
23
|
+
const timestamp = this.now().toISOString();
|
|
24
|
+
const turn = {
|
|
25
|
+
id: crypto.randomUUID(),
|
|
26
|
+
...clone(attributes),
|
|
27
|
+
status: 'received',
|
|
28
|
+
createdAt: timestamp,
|
|
29
|
+
updatedAt: timestamp
|
|
30
|
+
};
|
|
31
|
+
this.turns.set(turn.id, turn);
|
|
32
|
+
this.sourceIndex.set(sourceIdentity, turn.id);
|
|
33
|
+
return { turn: clone(turn), created: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async transition(turn, status, updates = {}) {
|
|
37
|
+
const current = this.turns.get(turn.id);
|
|
38
|
+
if (!current) throw new Error(`Unknown turn: ${turn.id}`);
|
|
39
|
+
if (!TURN_TRANSITIONS[current.status]?.includes(status)) {
|
|
40
|
+
throw new Error(`Invalid turn transition: ${current.status} -> ${status}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const updated = {
|
|
44
|
+
...current,
|
|
45
|
+
...clone(updates),
|
|
46
|
+
status,
|
|
47
|
+
updatedAt: this.now().toISOString()
|
|
48
|
+
};
|
|
49
|
+
this.turns.set(updated.id, updated);
|
|
50
|
+
return clone(updated);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getPending({ tenantKey, actorKey, channel, threadKey }) {
|
|
54
|
+
const candidates = [...this.turns.values()]
|
|
55
|
+
.filter(turn => (
|
|
56
|
+
turn.tenantKey === tenantKey &&
|
|
57
|
+
turn.actorKey === actorKey &&
|
|
58
|
+
turn.channel === channel &&
|
|
59
|
+
turn.threadKey === threadKey &&
|
|
60
|
+
turn.status === 'awaiting_confirmation'
|
|
61
|
+
))
|
|
62
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
63
|
+
let pending = null;
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
if (candidate.expiresAt && new Date(candidate.expiresAt) <= this.now()) {
|
|
66
|
+
await this.transition(candidate, 'expired');
|
|
67
|
+
} else {
|
|
68
|
+
pending = candidate;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return pending ? clone(pending) : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async claimPending({ tenantKey, actorKey, channel, threadKey, turnId, pendingActions }) {
|
|
76
|
+
const pending = this.turns.get(turnId);
|
|
77
|
+
if (!pending || pending.tenantKey !== tenantKey || pending.actorKey !== actorKey ||
|
|
78
|
+
pending.channel !== channel || pending.threadKey !== threadKey ||
|
|
79
|
+
pending.status !== 'awaiting_confirmation' ||
|
|
80
|
+
!(new Date(pending.expiresAt) > this.now()) ||
|
|
81
|
+
!isDeepStrictEqual(pending.pendingActions, pendingActions)) return null;
|
|
82
|
+
return await this.transition(pending, 'executing');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async get(id) {
|
|
86
|
+
const turn = this.turns.get(id);
|
|
87
|
+
return turn ? clone(turn) : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async list() {
|
|
91
|
+
return [...this.turns.values()].map(clone);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { MemoryTurnStore };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Select from an already authorized queue without executing or changing it.
|
|
4
|
+
// Callers own confirmation interpretation, atomic claiming, authorization at
|
|
5
|
+
// execution time, and receipts. A warning requires explicit approval; a general
|
|
6
|
+
// confirmation cannot override it. Existing ids and action objects are retained.
|
|
7
|
+
function selectPendingActions(actions, { decision, actionId = null } = {}) {
|
|
8
|
+
if (!Array.isArray(actions)) throw new TypeError('actions must be an array');
|
|
9
|
+
const unchanged = reason => ({ selected: [], remaining: [...actions], reason });
|
|
10
|
+
if (actions.length === 0) return unchanged('empty');
|
|
11
|
+
if (!['confirm', 'confirm_including_flagged', 'cancel'].includes(decision)) {
|
|
12
|
+
return unchanged('unconfirmed');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (actionId !== null && actionId !== undefined) {
|
|
16
|
+
const index = actions.findIndex(action => String(action.id) === String(actionId));
|
|
17
|
+
if (index === -1) return unchanged('not_found');
|
|
18
|
+
return {
|
|
19
|
+
selected: [actions[index]],
|
|
20
|
+
remaining: actions.filter((_, actionIndex) => actionIndex !== index),
|
|
21
|
+
reason: null
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (decision === 'cancel' || decision === 'confirm_including_flagged') {
|
|
26
|
+
return { selected: [...actions], remaining: [], reason: null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const selected = [];
|
|
30
|
+
const remaining = [];
|
|
31
|
+
for (const action of actions) {
|
|
32
|
+
(action.warning ? remaining : selected).push(action);
|
|
33
|
+
}
|
|
34
|
+
return { selected, remaining, reason: selected.length === 0 ? 'all_flagged' : null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { selectPendingActions };
|