@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 +281 -2
- package/index.js +34 -0
- package/package.json +29 -4
- package/src/channels.js +72 -0
- package/src/conversation.js +154 -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
package/README.md
CHANGED
|
@@ -1,3 +1,282 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Chat Runtime
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/channels.js
ADDED
|
@@ -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 };
|