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