@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 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 entry points for different application flows:
12
+ There are two application flows:
13
13
 
14
- - `runConversation` runs a bounded streaming conversation and tool loop. It
15
- delegates every tool call to the host's authorization/execution callback.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bliggy/chat-runtime",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Shared chat conversations, tool execution, and action confirmations powered by @bliggy/llm-client",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,9 +17,36 @@ function responseStateError(code, message, stopReason) {
17
17
  return error;
18
18
  }
19
19
 
20
- async function consumeTurn(streamTurn, request, onEvent) {
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
- onToolResult = () => {}
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
- result = await trackTurn(() => consumeTurn(streamTurn, request, onEvent), state);
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) throw new TypeError('executeTool is required for tool calls.');
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
- if (!outcome || outcome.output === undefined) {
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
- module.exports = { runConversation };
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 };