@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 CHANGED
@@ -1,3 +1,315 @@
1
- # Temporary Holding Version
1
+ # Chat Runtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@bliggy/chat-runtime` runs conversations and tools using `@bliggy/llm-client`.
4
+ Applications supply their prompts, authorized tools, storage and business rules.
5
+ The package also retains the channel orchestration API from
6
+ `@davejohnson/agent-channel-runtime` for Chat, Email, Twilio SMS and turn-based Voice.
7
+
8
+ ```sh
9
+ npm install @bliggy/chat-runtime
10
+ ```
11
+
12
+ There are two application flows:
13
+
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.
17
+ - `ChannelRuntime` authenticates channel messages, claims durable turns, checks
18
+ action policy and handles confirmation of pending turns. Its host supplies
19
+ planning, execution and storage callbacks.
20
+
21
+ `runConversation` does not implicitly create a `ChannelRuntime` or provide a
22
+ database. Hosts can use their existing confirmation store or compose the two
23
+ APIs. Model requests, transport retries and provider formats belong to
24
+ `@bliggy/llm-client`; this package does not introduce another provider retry layer.
25
+
26
+ ## Streaming conversations
27
+
28
+ ```js
29
+ const { runConversation } = require('@bliggy/chat-runtime');
30
+ const { createOpenAIAdapter } = require('@bliggy/llm-client');
31
+
32
+ const outcome = await runConversation({
33
+ messages: [{ role: 'user', content: [{ type: 'text', text: userText }] }],
34
+ tools: authorizedTools,
35
+ maxIterations: 16,
36
+ requestOptions: {
37
+ adapter: createOpenAIAdapter({ apiKey: process.env.OPENAI_API_KEY }),
38
+ provider: 'openai',
39
+ model: configuredModel,
40
+ maxOutputTokens: configuredOutputTokens,
41
+ promptCacheKey: conversationKey
42
+ },
43
+ getSystem: async () => buildCurrentSystemContext(),
44
+ onEvent: async event => sendToBrowser(event),
45
+ onAssistantMessage: async message => saveMessage(message),
46
+ executeTool: async toolCall => {
47
+ // Recheck current permissions and validate input here. Queue actions that
48
+ // need approval rather than executing them; return the actual outcome.
49
+ const result = await authorizeAndRunOrQueue(toolCall);
50
+ return { output: result, isError: false };
51
+ },
52
+ onToolResult: async message => saveMessage(message)
53
+ });
54
+ ```
55
+
56
+ The returned `status` is `completed`, `max_tokens`, or `limit`. Hosts render
57
+ their own output-budget and iteration-limit notices. Errors propagate to the
58
+ host. The final allowed request is text-only; a provider that disregards it
59
+ cannot execute another tool.
60
+
61
+ Completed replies must have `end_turn`, `stop_sequence`, or `refusal` as their
62
+ normalized stop reason. A `tool_call` stop reason must have matching tool-call
63
+ blocks; terminal text replies must not contain tool calls. Paused, failed,
64
+ incomplete, missing or unknown stop reasons throw `CHAT_RESPONSE_NOT_COMPLETE`
65
+ before response policy, persistence or tool execution. Contradictory tool state
66
+ throws `INVALID_CHAT_TOOL_STATE`. Both errors include `stopReason`. The loop does
67
+ not automatically resume provider-side paused work.
68
+
69
+ The loop forwards stream events but persists and executes only after a complete
70
+ final result and successful stream completion. Every tool call in that result
71
+ gets a sequential execution callback and matching result message. A tool outcome
72
+ may return `forceText: true` to make the next request text-only. Execution errors
73
+ must be converted by the host to safe `{ output, isError: true }` results when
74
+ the conversation should continue; uncaught errors stop the loop and are not retried.
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
+
108
+ Opaque reasoning and assistant phase survive in-memory tool continuation.
109
+ `onAssistantMessage` receives content without reasoning/thinking blocks. The
110
+ response state and returned `messages` still contain the full provider context:
111
+ do not persist or expose those as a transcript. Hosts remain responsible for
112
+ safe historical context, attachments, compaction and user-visible receipts.
113
+
114
+ Additional hooks:
115
+
116
+ - `streamTurn(request)` replaces the default `llm-client.streamChatTurn` boundary
117
+ when the application already wraps model configuration.
118
+ - `trackTurn(run, state)` wraps consumption of one provider stream for usage
119
+ accounting. Call and return `run()` once; replaying it creates another request.
120
+ - `getSystem(state)` refreshes context each iteration, including newly queued
121
+ actions. `state.iteration` starts at 1 and `state.textOnly` describes the request.
122
+ - `beforeResponse(state)` can return `{ retryMessages: [...] }` to correct an
123
+ unpersisted response. The rejected response and correction remain in memory,
124
+ and the retry consumes the same bounded iteration budget. Otherwise return
125
+ `null` to persist and process the response.
126
+
127
+ ## Partial and flagged confirmations
128
+
129
+ ```js
130
+ const { selectPendingActions } = require('@bliggy/chat-runtime');
131
+ const { selected, remaining, reason } = selectPendingActions(pendingActions, {
132
+ decision: 'confirm',
133
+ actionId: requestedActionId // omit for a general confirmation
134
+ });
135
+ ```
136
+
137
+ This pure helper preserves action IDs, order and objects. General `confirm`
138
+ selects actions without a truthy `warning`; flagged actions remain queued.
139
+ An explicit individual ID can select a flagged action. `cancel` selects one
140
+ action or the whole queue, including flagged actions. A host may use
141
+ `confirm_including_flagged` only when it has independently established explicit
142
+ approval of the warnings. Other decisions leave the queue unchanged.
143
+
144
+ `reason` is `empty`, `not_found`, `all_flagged`, `unconfirmed`, or `null` for a
145
+ successful selection. Selection is not authorization or an atomic claim. The
146
+ host must claim the same persisted queue, recheck current permissions, retain
147
+ unselected actions and record the actual results. Never accept a model-supplied
148
+ decision as proof of user approval.
149
+
150
+ ## Upgrade from agent-channel-runtime
151
+
152
+ The existing exports remain available under the new npm name. Upgrading from
153
+ 0.1.x also requires the 0.2 store contract below: add `getPending` and make
154
+ `claimPending` match the exact turn ID and pending-action snapshot. A package-name
155
+ replacement alone is not sufficient for a host using the older store contract.
156
+
157
+ ## Runtime flow
158
+
159
+ 1. Normalize the provider payload into an envelope.
160
+ 2. Authenticate the transport before resolving an actor.
161
+ 3. Resolve the tenant and actor through a host callback.
162
+ 4. Apply rate limits and idempotently claim the source message.
163
+ 5. Plan, authorize, and execute actions.
164
+ 6. Persist completion or hold risky actions for confirmation.
165
+ 7. Format the response for the originating channel.
166
+
167
+ The runtime returns formatted responses; the host owns durable delivery. Twilio
168
+ outbound SMS and call helpers are provided separately, so a webhook retry never
169
+ silently sends an email or text twice.
170
+
171
+ Turn statuses are:
172
+
173
+ ```text
174
+ received -> authenticated -> processing -> completed
175
+ \-> awaiting_confirmation -> executing -> completed
176
+ ```
177
+
178
+ ## Host contracts
179
+
180
+ Create `ChannelRuntime` with:
181
+
182
+ - `adapters`: channel adapters created by this package or the host.
183
+ - `turnStore`: `claim`, `transition`, `getPending`, and atomic `claimPending` methods.
184
+ - `resolvePrincipal`: returns trusted `{ tenantKey, actorKey, principal }`.
185
+ - `plan`: returns `{ content, summary, actions }`.
186
+ - `authorize`: explicitly classifies actions into `approved`,
187
+ `requiresConfirmation`, and `rejected` arrays. Unclassified actions fail
188
+ closed.
189
+ - `execute`: executes one action with the supplied idempotency key.
190
+ - Optional `render`, `rateLimit`, `onSecurityEvent`, and `onError` callbacks.
191
+ - Optional async `parseConfirmation(text, { pendingTurn, principal, envelope,
192
+ channel, context })`: returns `confirm`, `cancel`, `clarify`, or `null` (normal
193
+ planning). It runs only after transport authentication and principal resolution.
194
+ The default retains the legacy short commands; applications that support natural
195
+ conversation should supply their semantic interpreter. Never fabricate a new
196
+ message containing a command word. Unrecognized non-null results ask for
197
+ clarification without claiming the pending action.
198
+
199
+ The default confirmation lifetime is 24 hours. `confirmationTtlMs`,
200
+ `maxTextLength`, and `maxActions` are configurable constructor limits.
201
+
202
+ `MemoryTurnStore` is suitable for tests. Production hosts should implement the
203
+ same contract with durable storage and make `claimPending` an atomic row claim.
204
+ `getPending(scope, metadata)` returns the current unexpired pending turn for the
205
+ exact tenant, actor, channel and thread, without claiming it. The interpreter gets
206
+ a copy of that snapshot. `claimPending({ ...scope, turnId, pendingActions }, metadata)`
207
+ must atomically claim only that exact ID with the unchanged action set, still
208
+ pending and unexpired. A different/newer turn must never be substituted. This
209
+ two-phase store contract is required starting in 0.2.0; update durable stores when
210
+ upgrading. No database lock may be held during a model/provider call.
211
+ Confirmed execution receives a separate runtime-created `confirmation` value
212
+ (`decision`, `turnId`, `sourceKey`); ordinary execution receives `null`. The host
213
+ must not accept equivalent claims from model arguments. Policy is rechecked after
214
+ the claim. Interpretation is not an authorization decision.
215
+ Store methods receive a second `{ principal, context }` argument so a host can
216
+ persist through already-resolved tenant associations without querying global
217
+ identifiers again. Runtime callbacks also receive the original trusted
218
+ `context`; it is never copied into the persisted turn automatically.
219
+
220
+ ```js
221
+ const {
222
+ ChannelRuntime,
223
+ MemoryTurnStore,
224
+ createChatAdapter
225
+ } = require('@bliggy/chat-runtime');
226
+
227
+ const runtime = new ChannelRuntime({
228
+ adapters: {
229
+ chat: createChatAdapter({
230
+ authenticate: async (_envelope, context) => ({
231
+ allowed: context.sessionAuthenticated,
232
+ method: 'session'
233
+ })
234
+ })
235
+ },
236
+ turnStore: new MemoryTurnStore(),
237
+ resolvePrincipal: async ({ context }) => context.principalResolution,
238
+ plan: async ({ envelope }) => planner.plan(envelope.text),
239
+ authorize: async input => policy.authorize(input),
240
+ execute: async input => tools.execute(input)
241
+ });
242
+ ```
243
+
244
+ The host must generate a unique source key for each Chat message and reuse it
245
+ when retrying that same message.
246
+ Never accept tenant or actor identifiers from the untrusted channel payload.
247
+
248
+ ## Twilio
249
+
250
+ `createTwilioChannels` returns `sms`, `voice`, `sendSms`, and `startCall`.
251
+ Inbound adapters validate `X-Twilio-Signature` with the official Twilio SDK.
252
+ The host must pass the exact public request URL and every form parameter:
253
+
254
+ ```js
255
+ const { ChannelRuntime, createTwilioChannels } = require('@bliggy/chat-runtime');
256
+
257
+ const channels = createTwilioChannels({
258
+ accountSid: process.env.TWILIO_ACCOUNT_SID,
259
+ authToken: process.env.TWILIO_AUTH_TOKEN,
260
+ messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
261
+ voiceFrom: process.env.TWILIO_VOICE_NUMBER,
262
+ voiceActionUrl: 'https://app.example.com/webhooks/twilio/voice'
263
+ });
264
+
265
+ // Supply the application's durable store and callbacks described above.
266
+ const twilioRuntime = new ChannelRuntime({
267
+ adapters: { sms: channels.sms, voice: channels.voice },
268
+ turnStore,
269
+ resolvePrincipal,
270
+ plan,
271
+ authorize,
272
+ execute
273
+ });
274
+
275
+ await twilioRuntime.handle({
276
+ channel: 'sms',
277
+ payload: req.body,
278
+ context: {
279
+ url: publicRequestUrl,
280
+ params: req.body,
281
+ headers: req.headers
282
+ }
283
+ });
284
+ ```
285
+
286
+ Voice uses Programmable Voice speech/DTMF `Gather`. The generated TwiML adds a
287
+ fresh `turnKey` query parameter to every Gather action URL. The host must pass
288
+ that query value back as `context.turnKey`; this distinguishes successive turns
289
+ inside one `CallSid` and makes webhook retries idempotent. Twilio's optional
290
+ speech `Confidence` value is recorded as metadata but is never trusted as an
291
+ authentication signal.
292
+
293
+ Real-time bidirectional Media Streams are intentionally outside this package
294
+ version and should be added as a separate streaming adapter.
295
+
296
+ ## Security rules
297
+
298
+ - Authentication happens before tenant or actor resolution and before storage.
299
+ - Provider payloads never choose their own tenant or actor.
300
+ - Duplicate source messages return the stored result without replanning.
301
+ - Confirmations are scoped to tenant, actor, channel, and thread.
302
+ - Asynchronous interpretation binds to an exact pending-action snapshot; a stale,
303
+ changed, expired, cancelled or concurrently claimed snapshot cannot execute.
304
+ - Hosts own semantic accuracy: keep classifier inputs limited to the authenticated
305
+ current reply and application-owned action descriptions, excluding document,
306
+ assistant and tool text. Partial/conditional/uncertain acknowledgment must not
307
+ become approval of every action in a batch.
308
+ - Policy is fail-closed and is checked again when confirmation is received.
309
+ - Authentication records stored on a turn exclude secrets and signatures.
310
+ - Security-event callbacks receive identifiers and verdicts, not message text.
311
+
312
+ Twilio webhook validation follows:
313
+
314
+ - https://www.twilio.com/docs/usage/webhooks/webhooks-security
315
+ - https://www.twilio.com/docs/voice/twiml/gather
package/index.js ADDED
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ ChannelRuntime,
5
+ ChannelRuntimeError,
6
+ EnvelopeValidationError,
7
+ SecurityError,
8
+ RateLimitError,
9
+ TURN_STATUSES,
10
+ TURN_TRANSITIONS,
11
+ parseDecision
12
+ } = require('./src/runtime');
13
+ const { MemoryTurnStore } = require('./src/memoryTurnStore');
14
+ const { createChatAdapter, createEmailAdapter } = require('./src/channels');
15
+ const { createTwilioChannels } = require('./src/twilio');
16
+ const { runConversation, streamConversation } = require('./src/conversation');
17
+ const { selectPendingActions } = require('./src/pendingActions');
18
+
19
+ module.exports = {
20
+ ChannelRuntime,
21
+ ChannelRuntimeError,
22
+ EnvelopeValidationError,
23
+ SecurityError,
24
+ RateLimitError,
25
+ MemoryTurnStore,
26
+ TURN_STATUSES,
27
+ TURN_TRANSITIONS,
28
+ parseDecision,
29
+ createChatAdapter,
30
+ createEmailAdapter,
31
+ createTwilioChannels,
32
+ runConversation,
33
+ streamConversation,
34
+ selectPendingActions
35
+ };
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@bliggy/chat-runtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.1",
4
+ "description": "Shared chat conversations, tool execution, and action confirmations powered by @bliggy/llm-client",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/davejohnson/agent-channel-runtime.git"
8
+ },
9
+ "main": "index.js",
10
+ "exports": "./index.js",
11
+ "files": [
12
+ "index.js",
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --test test/*.node.js"
18
+ },
19
+ "engines": {
20
+ "node": ">=24 <25"
21
+ },
22
+ "dependencies": {
23
+ "@bliggy/llm-client": "0.1.0",
24
+ "twilio": "^6.0.2"
25
+ },
26
+ "publishConfig": {
27
+ "registry": "https://registry.npmjs.org",
28
+ "access": "public"
29
+ },
30
+ "license": "ISC"
31
+ }
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ function header(headers, name) {
4
+ if (!headers || typeof headers !== 'object') return null;
5
+ const key = Object.keys(headers).find(candidate => candidate.toLowerCase() === name.toLowerCase());
6
+ return key ? headers[key] : null;
7
+ }
8
+
9
+ function requireAuthenticator(authenticate, channel) {
10
+ if (typeof authenticate !== 'function') {
11
+ throw new TypeError(`${channel} authenticate callback is required`);
12
+ }
13
+ return authenticate;
14
+ }
15
+
16
+ function createChatAdapter({ authenticate, format = null } = {}) {
17
+ const verify = requireAuthenticator(authenticate, 'chat');
18
+ return {
19
+ async normalize(payload, context) {
20
+ return {
21
+ channel: 'chat',
22
+ sourceKey: payload?.sourceKey || context.sourceKey || '',
23
+ threadKey: payload?.threadKey || payload?.conversationId || context.threadKey || '',
24
+ sender: null,
25
+ recipient: 'agent',
26
+ text: payload?.text ?? payload?.message ?? '',
27
+ attachments: payload?.attachments || [],
28
+ metadata: payload?.metadata || null
29
+ };
30
+ },
31
+ authenticate: verify,
32
+ async format(output, details) {
33
+ if (format) return await format(output, details);
34
+ return {
35
+ content: output.content || '',
36
+ results: output.results || [],
37
+ pendingActions: output.pendingActions || []
38
+ };
39
+ }
40
+ };
41
+ }
42
+
43
+ function createEmailAdapter({ authenticate, format = null } = {}) {
44
+ const verify = requireAuthenticator(authenticate, 'email');
45
+ return {
46
+ async normalize(payload) {
47
+ const sourceKey = payload?.messageId || header(payload?.headers, 'message-id') || '';
48
+ const inReplyTo = payload?.inReplyTo || header(payload?.headers, 'in-reply-to');
49
+ return {
50
+ channel: 'email',
51
+ sourceKey,
52
+ threadKey: payload?.threadKey || inReplyTo || sourceKey,
53
+ sender: payload?.from || null,
54
+ recipient: payload?.to || null,
55
+ subject: payload?.subject || '(no subject)',
56
+ text: payload?.text || '',
57
+ attachments: payload?.attachments || [],
58
+ metadata: payload?.metadata || null
59
+ };
60
+ },
61
+ authenticate: verify,
62
+ async format(output, details) {
63
+ if (format) return await format(output, details);
64
+ const subject = details.envelope.subject.startsWith('Re:')
65
+ ? details.envelope.subject
66
+ : `Re: ${details.envelope.subject}`;
67
+ return { subject, text: output.content || '', html: null };
68
+ }
69
+ };
70
+ }
71
+
72
+ module.exports = { createChatAdapter, createEmailAdapter };