@bliggy/chat-runtime 0.3.0 → 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 +36 -3
- package/index.js +2 -1
- package/package.json +1 -1
- package/src/conversation.js +157 -17
package/README.md
CHANGED
|
@@ -9,10 +9,11 @@ The package also retains the channel orchestration API from
|
|
|
9
9
|
npm install @bliggy/chat-runtime
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
There are two
|
|
12
|
+
There are two application flows:
|
|
13
13
|
|
|
14
|
-
- `runConversation` runs a bounded
|
|
15
|
-
|
|
14
|
+
- `runConversation` runs a bounded conversation and tool loop; `streamConversation`
|
|
15
|
+
exposes that same loop as an async iterator. Both delegate tool calls to the
|
|
16
|
+
host's authorization/execution callback.
|
|
16
17
|
- `ChannelRuntime` authenticates channel messages, claims durable turns, checks
|
|
17
18
|
action policy and handles confirmation of pending turns. Its host supplies
|
|
18
19
|
planning, execution and storage callbacks.
|
|
@@ -72,6 +73,38 @@ may return `forceText: true` to make the next request text-only. Execution error
|
|
|
72
73
|
must be converted by the host to safe `{ output, isError: true }` results when
|
|
73
74
|
the conversation should continue; uncaught errors stop the loop and are not retried.
|
|
74
75
|
|
|
76
|
+
When the host authorizes or queues a whole proposal together, supply
|
|
77
|
+
`executeTools(toolCalls, state)` instead of `executeTool`. The callbacks are
|
|
78
|
+
mutually exclusive. The batch callback runs once per completed assistant response
|
|
79
|
+
and returns one `{ output, isError?, forceText? }` outcome per call, in the original
|
|
80
|
+
order. The loop appends one user message containing all tool results, then calls
|
|
81
|
+
the provider again. `onToolResult(message, state)` receives that combined message
|
|
82
|
+
once, with `state.toolCalls` and `state.outcomes`. An omitted `isError` remains
|
|
83
|
+
omitted on this batch path. The host owns batch authorization, transaction scope,
|
|
84
|
+
durable claims and confirmation; this callback does not make a batch atomic.
|
|
85
|
+
|
|
86
|
+
For async-iterator consumers, pass the same options to `streamConversation`:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
const { streamConversation } = require('@bliggy/chat-runtime');
|
|
90
|
+
|
|
91
|
+
for await (const event of streamConversation(options)) {
|
|
92
|
+
if (event.type === 'done') handleOutcome(event.result);
|
|
93
|
+
else sendToBrowser(event);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The iterator yields normalized provider events and application events sent with
|
|
98
|
+
`await state.emit(event)`, followed by one `{ type: 'done', result: outcome }`.
|
|
99
|
+
It starts when first consumed, and waits for the consumer to advance after each
|
|
100
|
+
event. An interrupted provider stream throws without a successful `done` event.
|
|
101
|
+
Both APIs accept an optional `AbortSignal` as `signal`. Closing or throwing into
|
|
102
|
+
the iterator also cancels immediately, including while `next()` is pending.
|
|
103
|
+
Cancellation prevents later model calls, tool callbacks and persistence callbacks;
|
|
104
|
+
it cannot undo an already-started host action or abort an in-flight provider
|
|
105
|
+
request. Host callbacks that emit progress should await `state.emit` before
|
|
106
|
+
starting their next action.
|
|
107
|
+
|
|
75
108
|
Opaque reasoning and assistant phase survive in-memory tool continuation.
|
|
76
109
|
`onAssistantMessage` receives content without reasoning/thinking blocks. The
|
|
77
110
|
response state and returned `messages` still contain the full provider context:
|
package/index.js
CHANGED
|
@@ -13,7 +13,7 @@ const {
|
|
|
13
13
|
const { MemoryTurnStore } = require('./src/memoryTurnStore');
|
|
14
14
|
const { createChatAdapter, createEmailAdapter } = require('./src/channels');
|
|
15
15
|
const { createTwilioChannels } = require('./src/twilio');
|
|
16
|
-
const { runConversation } = require('./src/conversation');
|
|
16
|
+
const { runConversation, streamConversation } = require('./src/conversation');
|
|
17
17
|
const { selectPendingActions } = require('./src/pendingActions');
|
|
18
18
|
|
|
19
19
|
module.exports = {
|
|
@@ -30,5 +30,6 @@ module.exports = {
|
|
|
30
30
|
createEmailAdapter,
|
|
31
31
|
createTwilioChannels,
|
|
32
32
|
runConversation,
|
|
33
|
+
streamConversation,
|
|
33
34
|
selectPendingActions
|
|
34
35
|
};
|
package/package.json
CHANGED
package/src/conversation.js
CHANGED
|
@@ -17,9 +17,36 @@ function responseStateError(code, message, stopReason) {
|
|
|
17
17
|
return error;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
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);
|
|
21
47
|
let result = null;
|
|
22
48
|
for await (const event of streamTurn(request)) {
|
|
49
|
+
assertActive(signal);
|
|
23
50
|
if (event.type === 'done') {
|
|
24
51
|
if (result) throw new Error('Chat stream returned more than one final result.');
|
|
25
52
|
result = event.result;
|
|
@@ -27,6 +54,7 @@ async function consumeTurn(streamTurn, request, onEvent) {
|
|
|
27
54
|
await onEvent(event);
|
|
28
55
|
}
|
|
29
56
|
}
|
|
57
|
+
assertActive(signal);
|
|
30
58
|
if (!result) throw new Error('Chat stream ended without a final result.');
|
|
31
59
|
return result;
|
|
32
60
|
}
|
|
@@ -43,7 +71,9 @@ async function runConversation({
|
|
|
43
71
|
beforeResponse = () => null,
|
|
44
72
|
onAssistantMessage = () => {},
|
|
45
73
|
executeTool = null,
|
|
46
|
-
|
|
74
|
+
executeTools = null,
|
|
75
|
+
onToolResult = () => {},
|
|
76
|
+
signal = null
|
|
47
77
|
} = {}) {
|
|
48
78
|
if (!Array.isArray(initialMessages) || initialMessages.length === 0) {
|
|
49
79
|
throw new TypeError('messages must be a non-empty array.');
|
|
@@ -57,26 +87,36 @@ async function runConversation({
|
|
|
57
87
|
})) {
|
|
58
88
|
if (typeof callback !== 'function') throw new TypeError(`${name} must be a function.`);
|
|
59
89
|
}
|
|
60
|
-
for (const [name, callback] of Object.entries({ getSystem, executeTool })) {
|
|
90
|
+
for (const [name, callback] of Object.entries({ getSystem, executeTool, executeTools })) {
|
|
61
91
|
if (callback !== null && typeof callback !== 'function') throw new TypeError(`${name} must be a function.`);
|
|
62
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
|
+
};
|
|
63
100
|
|
|
64
101
|
const messages = [...initialMessages];
|
|
65
102
|
let forceText = false;
|
|
66
103
|
let result = null;
|
|
67
104
|
|
|
68
105
|
for (let iteration = 1; iteration <= maxIterations; iteration += 1) {
|
|
106
|
+
assertActive(signal);
|
|
69
107
|
const textOnly = forceText || iteration === maxIterations || tools.length === 0;
|
|
70
|
-
const state = { iteration, textOnly, messages };
|
|
108
|
+
const state = { iteration, textOnly, messages, emit };
|
|
71
109
|
const request = {
|
|
72
110
|
...requestOptions,
|
|
73
111
|
system: getSystem ? await getSystem(state) : requestOptions.system,
|
|
74
|
-
messages,
|
|
112
|
+
messages: [...messages],
|
|
75
113
|
tools,
|
|
76
114
|
toolChoice: textOnly ? 'none' : 'auto',
|
|
77
115
|
parallelToolCalls: false
|
|
78
116
|
};
|
|
79
|
-
|
|
117
|
+
assertActive(signal);
|
|
118
|
+
result = await trackTurn(() => consumeTurn(streamTurn, request, emit, signal), state);
|
|
119
|
+
assertActive(signal);
|
|
80
120
|
if (!result || result.message?.role !== 'assistant' || !Array.isArray(result.message.content)) {
|
|
81
121
|
throw new TypeError('Chat result must contain a normalized assistant message.');
|
|
82
122
|
}
|
|
@@ -108,6 +148,7 @@ async function runConversation({
|
|
|
108
148
|
if (textOnly && toolCalls.length) return finish('limit');
|
|
109
149
|
|
|
110
150
|
const policy = await beforeResponse(responseState);
|
|
151
|
+
assertActive(signal);
|
|
111
152
|
if (policy?.retryMessages !== undefined) {
|
|
112
153
|
if (!Array.isArray(policy.retryMessages) || policy.retryMessages.length === 0) {
|
|
113
154
|
throw new TypeError('beforeResponse.retryMessages must be a non-empty array.');
|
|
@@ -115,34 +156,49 @@ async function runConversation({
|
|
|
115
156
|
messages.push(assistantMessage, ...policy.retryMessages);
|
|
116
157
|
continue;
|
|
117
158
|
}
|
|
118
|
-
if (toolCalls.length && !executeTool
|
|
159
|
+
if (toolCalls.length && !executeTool && !executeTools) {
|
|
160
|
+
throw new TypeError('executeTool or executeTools is required for tool calls.');
|
|
161
|
+
}
|
|
119
162
|
|
|
120
163
|
const persistedMessage = {
|
|
121
164
|
...assistantMessage,
|
|
122
165
|
content: assistantMessage.content.filter(block => !PRIVATE_BLOCK_TYPES.has(block.type))
|
|
123
166
|
};
|
|
124
167
|
await onAssistantMessage(persistedMessage, responseState);
|
|
168
|
+
assertActive(signal);
|
|
125
169
|
// Keep the complete response, including opaque provider reasoning, in the
|
|
126
170
|
// in-memory replay. Only the persistence callback receives filtered content.
|
|
127
171
|
messages.push(assistantMessage);
|
|
128
172
|
if (toolCalls.length === 0) return finish('completed');
|
|
129
173
|
|
|
130
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
|
+
|
|
131
192
|
for (const toolCall of toolCalls) {
|
|
193
|
+
assertActive(signal);
|
|
132
194
|
const outcome = await executeTool(toolCall, responseState);
|
|
133
|
-
|
|
134
|
-
throw new TypeError('executeTool must return an outcome with output.');
|
|
135
|
-
}
|
|
195
|
+
assertActive(signal);
|
|
136
196
|
const message = {
|
|
137
197
|
role: 'user',
|
|
138
|
-
content: [
|
|
139
|
-
type: 'tool_result',
|
|
140
|
-
toolCallId: toolCall.id,
|
|
141
|
-
output: outcome.output,
|
|
142
|
-
isError: Boolean(outcome.isError)
|
|
143
|
-
}]
|
|
198
|
+
content: [toolResultBlock(toolCall, outcome, 'executeTool')]
|
|
144
199
|
};
|
|
145
200
|
await onToolResult(message, { ...responseState, toolCall, outcome });
|
|
201
|
+
assertActive(signal);
|
|
146
202
|
messages.push(message);
|
|
147
203
|
forceText ||= Boolean(outcome.forceText);
|
|
148
204
|
}
|
|
@@ -151,4 +207,88 @@ async function runConversation({
|
|
|
151
207
|
return { status: 'limit', result, messages, iterations: maxIterations };
|
|
152
208
|
}
|
|
153
209
|
|
|
154
|
-
|
|
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 };
|