@ixo/oracle-runtime-workers 0.12.0 → 0.15.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.
Files changed (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +14 -9
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +198 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +523 -111
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -0,0 +1,270 @@
1
+ import type { DoSqliteDatabase } from '../sqlite/database';
2
+ import type { RunRecord } from '../do/run-store';
3
+ import type { TurnIdentity, TurnRequest } from '../do/contracts';
4
+ import { parseReplyPlan, planText } from '../delivery/schema';
5
+ import type { ReplyPlan } from '../delivery/types';
6
+ import {
7
+ ChannelError,
8
+ channelRequestHash,
9
+ type ChannelTurnInput,
10
+ type ChannelTurnResponse,
11
+ } from './contract';
12
+
13
+ interface ReceiptRow extends Record<string, string | number | null> {
14
+ binding_id: string;
15
+ request_id: string;
16
+ request_hash: string;
17
+ run_id: string;
18
+ session_id: string | null;
19
+ /** 1 once the assistant reply was mirrored into the Companion room. */
20
+ reply_mirrored: number;
21
+ }
22
+
23
+ export interface ChannelTurnsHost {
24
+ createSession(identity: TurnIdentity, markerTxnId: string): Promise<string>;
25
+ assertSession(identity: TurnIdentity, sessionId: string): Promise<void>;
26
+ /** Throws a `ChannelError` when the user has no stored delegation for this oracle. */
27
+ requireDelegation(identity: TurnIdentity): Promise<void>;
28
+ getRun(runId: string): Promise<RunRecord | undefined>;
29
+ /** The Reply Plan (JSON) a finished run was built into, if any. */
30
+ getPlan(runId: string): Promise<string | null>;
31
+ wasPruned(runId: string): Promise<boolean>;
32
+ begin(runId: string, request: TurnRequest): Promise<RunRecord>;
33
+ mirror(
34
+ request: TurnRequest,
35
+ text: string,
36
+ author: 'user' | 'oracle',
37
+ ): Promise<void>;
38
+ changed(): Promise<void>;
39
+ }
40
+
41
+ export class ChannelTurns {
42
+ private admission: Promise<unknown> = Promise.resolve();
43
+ private setupPromise: Promise<void> | undefined;
44
+
45
+ constructor(
46
+ private readonly db: DoSqliteDatabase,
47
+ private readonly host: ChannelTurnsHost,
48
+ ) {}
49
+
50
+ private setup(): Promise<void> {
51
+ this.setupPromise ??= (async () => {
52
+ await this.db.run(`CREATE TABLE IF NOT EXISTS channel_requests (
53
+ binding_id TEXT NOT NULL, request_id TEXT NOT NULL, request_hash TEXT NOT NULL,
54
+ run_id TEXT NOT NULL UNIQUE, session_id TEXT,
55
+ reply_mirrored INTEGER NOT NULL DEFAULT 0,
56
+ PRIMARY KEY (binding_id, request_id)
57
+ )`);
58
+ // Tables created before the column existed (CREATE TABLE IF NOT
59
+ // EXISTS leaves them as they were).
60
+ const columns = await this.db.exec<{ name: string }>(
61
+ 'PRAGMA table_info(channel_requests)',
62
+ );
63
+ if (!columns.some((c) => c.name === 'reply_mirrored'))
64
+ await this.db.run(
65
+ 'ALTER TABLE channel_requests ADD COLUMN reply_mirrored INTEGER NOT NULL DEFAULT 0',
66
+ );
67
+ await this.db.run(`CREATE TABLE IF NOT EXISTS channel_sessions (
68
+ binding_id TEXT PRIMARY KEY, session_id TEXT NOT NULL
69
+ )`);
70
+ })().catch((error: unknown) => {
71
+ this.setupPromise = undefined;
72
+ throw error;
73
+ });
74
+ return this.setupPromise;
75
+ }
76
+
77
+ /** Admissions, reply deliveries and session releases run one at a time. */
78
+ private serialize<T>(work: () => Promise<T>): Promise<T> {
79
+ const next = this.admission.then(work);
80
+ this.admission = next.catch(() => undefined);
81
+ return next;
82
+ }
83
+
84
+ submit(
85
+ identity: TurnIdentity,
86
+ input: ChannelTurnInput,
87
+ requestHash: string,
88
+ ): Promise<ChannelTurnResponse> {
89
+ return this.serialize(() => this.admit(identity, input, requestHash));
90
+ }
91
+
92
+ /**
93
+ * Mirror a finished run's reply into the Companion room once: the receipt
94
+ * records the delivery, so later polls of the same request do not send it
95
+ * again. Called when the run ends; a poll retries a delivery that failed.
96
+ * `text` is the model's reply; a run with a Reply Plan mirrors the plan
97
+ * instead, as the channel user received it.
98
+ */
99
+ deliverReply(
100
+ request: TurnRequest,
101
+ runId: string,
102
+ text: string,
103
+ ): Promise<void> {
104
+ return this.serialize(async () => {
105
+ await this.deliver(request, runId, await this.reply(runId, text));
106
+ });
107
+ }
108
+
109
+ /** A finished run's Reply Plan, and the reply as the channel user sees it. */
110
+ private async reply(
111
+ runId: string,
112
+ text: string,
113
+ ): Promise<{ plan: ReplyPlan | null; rendered: string }> {
114
+ const plan = parseReplyPlan(await this.host.getPlan(runId));
115
+ return { plan, rendered: plan ? planText(plan) : text };
116
+ }
117
+
118
+ /**
119
+ * The session was deleted: release every binding that pointed at it, so
120
+ * the binding's next message opens a new Companion session. Returns
121
+ * whether a binding was released.
122
+ */
123
+ forgetSession(sessionId: string): Promise<boolean> {
124
+ return this.serialize(async () => {
125
+ await this.setup();
126
+ const released = await this.db.run(
127
+ 'DELETE FROM channel_sessions WHERE session_id = ?',
128
+ [sessionId],
129
+ );
130
+ if (released.changes === 0) return false;
131
+ await this.host.changed();
132
+ return true;
133
+ });
134
+ }
135
+
136
+ private async deliver(
137
+ request: TurnRequest,
138
+ runId: string,
139
+ reply: { rendered: string },
140
+ ): Promise<void> {
141
+ await this.setup();
142
+ const receipt = await this.db.get<{ reply_mirrored: number }>(
143
+ 'SELECT reply_mirrored FROM channel_requests WHERE run_id = ?',
144
+ [runId],
145
+ );
146
+ if (Number(receipt?.reply_mirrored ?? 0) === 1) return;
147
+ await this.host.mirror(request, reply.rendered, 'oracle');
148
+ await this.db.run(
149
+ 'UPDATE channel_requests SET reply_mirrored = 1 WHERE run_id = ?',
150
+ [runId],
151
+ );
152
+ await this.host.changed();
153
+ }
154
+
155
+ private async admit(
156
+ identity: TurnIdentity,
157
+ input: ChannelTurnInput,
158
+ requestHash: string,
159
+ ): Promise<ChannelTurnResponse> {
160
+ await this.setup();
161
+ if (
162
+ !identity.channel ||
163
+ identity.channel.bindingId !== input.bindingId ||
164
+ identity.channel.bindingRevision !== input.bindingRevision ||
165
+ identity.channel.provider !== input.provider
166
+ )
167
+ throw new ChannelError(
168
+ 403,
169
+ 'Channel identity does not match this request',
170
+ );
171
+ const runId = `channel_${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`;
172
+ // Checked before a receipt is written: the receipt of a pruned run is
173
+ // gone (run-store.ts), and a new one must not take its place.
174
+ if (await this.host.wasPruned(runId))
175
+ throw new ChannelError(
176
+ 410,
177
+ 'Channel response has expired; this request cannot execute again',
178
+ );
179
+ const admitted = await this.db.run(
180
+ `INSERT OR IGNORE INTO channel_requests
181
+ (binding_id, request_id, request_hash, run_id) VALUES (?, ?, ?, ?)`,
182
+ [input.bindingId, input.requestId, requestHash, runId],
183
+ );
184
+ // A poll of an admitted request changes nothing.
185
+ if (admitted.changes === 1) await this.host.changed();
186
+ const receipt = await this.db.get<ReceiptRow>(
187
+ 'SELECT * FROM channel_requests WHERE binding_id = ? AND request_id = ?',
188
+ [input.bindingId, input.requestId],
189
+ );
190
+ if (!receipt || receipt.request_hash !== requestHash)
191
+ throw new ChannelError(
192
+ 409,
193
+ 'This request ID already belongs to another message',
194
+ );
195
+ let sessionId = receipt.session_id;
196
+ // The session is checked when this request is bound to it, and again
197
+ // before the request starts a run; a poll of a run that exists already
198
+ // only reads its receipt and row.
199
+ let sessionChecked = false;
200
+ if (!sessionId) {
201
+ const binding = await this.db.get<{ session_id: string }>(
202
+ 'SELECT session_id FROM channel_sessions WHERE binding_id = ?',
203
+ [input.bindingId],
204
+ );
205
+ if (binding && input.sessionId && binding.session_id !== input.sessionId)
206
+ throw new ChannelError(409, 'This channel already has another session');
207
+ sessionId =
208
+ binding?.session_id ??
209
+ input.sessionId ??
210
+ // The marker transaction id is fixed per binding AND the request that
211
+ // opens the session: a retry of that request reuses the marker event,
212
+ // while a binding whose session was deleted gets a new one.
213
+ (await this.host.createSession(
214
+ identity,
215
+ `channel-session-${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`,
216
+ ));
217
+ await this.host.assertSession(identity, sessionId);
218
+ sessionChecked = true;
219
+ await this.db.transaction(async () => {
220
+ await this.db.run(
221
+ 'INSERT OR IGNORE INTO channel_sessions (binding_id, session_id) VALUES (?, ?)',
222
+ [input.bindingId, sessionId],
223
+ );
224
+ await this.db.run(
225
+ 'UPDATE channel_requests SET session_id = ? WHERE run_id = ?',
226
+ [sessionId, runId],
227
+ );
228
+ });
229
+ await this.host.changed();
230
+ }
231
+ const request: TurnRequest = {
232
+ identity,
233
+ sessionId,
234
+ message: input.message,
235
+ client: 'channel',
236
+ requestId: input.requestId,
237
+ multitask: 'enqueue',
238
+ channel: {
239
+ provider: input.provider,
240
+ bindingId: input.bindingId,
241
+ remoteMessageRef: input.remoteMessageRef,
242
+ },
243
+ };
244
+ let record = await this.host.getRun(runId);
245
+ if (!record) {
246
+ if (!sessionChecked) await this.host.assertSession(identity, sessionId);
247
+ await this.host.requireDelegation(identity);
248
+ await this.host.mirror(request, request.message, 'user');
249
+ record = await this.host.begin(runId, request);
250
+ }
251
+ const reply =
252
+ record.status === 'finished'
253
+ ? await this.reply(runId, record.partialText ?? '')
254
+ : null;
255
+ // The Companion room records the reply the channel user received, once.
256
+ if (reply) await this.deliver(request, runId, reply);
257
+ return {
258
+ requestId: input.requestId,
259
+ runId,
260
+ sessionId,
261
+ status: record.status,
262
+ ...(record.messageId ? { messageId: record.messageId } : {}),
263
+ // `text` is the whole reply as one message for gateways that predate
264
+ // plans: the rendered plan when there is one (it carries the artefact
265
+ // links), else the model's text.
266
+ ...(reply ? { text: reply.rendered } : {}),
267
+ ...(reply?.plan ? { plan: reply.plan } : {}),
268
+ };
269
+ }
270
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * The turn's LLM adapter, metered: every model it hands out carries a
3
+ * callback that reserves the call against the `TurnBudget` before the
4
+ * provider is contacted and settles the reservation to the provider's
5
+ * reported usage when the call ends. Helper models (summarizer, attachment
6
+ * extraction, a plugin's own `ctx.llm.get`) are covered the same way as the
7
+ * main model, since they all come from this adapter.
8
+ *
9
+ * The same handler is also put in the graph's run config (`callbacks`) so a
10
+ * model the host constructed elsewhere and passed in directly is still
11
+ * charged; a call reported through both paths is counted once.
12
+ */
13
+ import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
14
+ import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
15
+ import type { LLMResult } from '@langchain/core/outputs';
16
+ import type { ChatOpenAIFields, ModelRole } from '../plugin-api/types';
17
+ import {
18
+ estimateContentTokens,
19
+ messageContentTokens,
20
+ messageToolCallTokens,
21
+ } from './context-budget';
22
+ import type { LlmAdapter } from './runtime-context';
23
+ import type { TurnBudget } from './turn-budget';
24
+
25
+ export interface BudgetedLlmOptions {
26
+ budget: TurnBudget;
27
+ /** Tokens reserved for the reply of each call (the context budget's output reserve). */
28
+ outputReserveTokens: number;
29
+ /** The turn's abort signal: a call after the abort is refused before it starts. */
30
+ signal?: AbortSignal;
31
+ }
32
+
33
+ export interface BudgetedLlm extends LlmAdapter {
34
+ /** Put this in the run config's `callbacks` as well. */
35
+ readonly callback: BaseCallbackHandler;
36
+ }
37
+
38
+ /**
39
+ * Key of a request's tool list for the per-turn schema estimate: the tool
40
+ * names in order. Within one turn a name always carries the same schema (the
41
+ * tools are bound when the turn's graph is built), so the names identify
42
+ * the list; anything without a recognisable name is not cached.
43
+ */
44
+ function toolListKey(tools: unknown): string | undefined {
45
+ if (!Array.isArray(tools)) return undefined;
46
+ const names: string[] = [];
47
+ for (const entry of tools) {
48
+ const fn: unknown =
49
+ entry && typeof entry === 'object' && 'function' in entry
50
+ ? entry.function
51
+ : entry;
52
+ const name: unknown =
53
+ fn && typeof fn === 'object' && 'name' in fn ? fn.name : undefined;
54
+ if (typeof name !== 'string') return undefined;
55
+ names.push(name);
56
+ }
57
+ return names.join('\n');
58
+ }
59
+
60
+ /**
61
+ * What one model call sends, reduced to what costs tokens. Message estimates
62
+ * are memoised per message (`messageContentTokens`), the tool schemas per
63
+ * tool list in `toolTokens`, so a long history is not re-serialised on every
64
+ * step of the turn.
65
+ */
66
+ function estimateRequestTokens(
67
+ messages: LLMResultMessages,
68
+ invocationParams: unknown,
69
+ toolTokens: Map<string, number>,
70
+ ): number {
71
+ let tokens = 0;
72
+ for (const batch of messages)
73
+ for (const message of batch) {
74
+ tokens += messageContentTokens(message) + 4;
75
+ if ('tool_calls' in message && Array.isArray(message.tool_calls))
76
+ tokens += messageToolCallTokens(message, message.tool_calls);
77
+ }
78
+ if (
79
+ invocationParams &&
80
+ typeof invocationParams === 'object' &&
81
+ 'tools' in invocationParams
82
+ ) {
83
+ const { tools } = invocationParams;
84
+ const key = toolListKey(tools);
85
+ let schema = key === undefined ? undefined : toolTokens.get(key);
86
+ if (schema === undefined) {
87
+ schema = estimateContentTokens(tools);
88
+ if (key !== undefined) toolTokens.set(key, schema);
89
+ }
90
+ tokens += schema;
91
+ }
92
+ return tokens;
93
+ }
94
+
95
+ /** The summarizer tags its model call with this `lc_source` (LangChain's summarization middleware). */
96
+ const SUMMARIZATION_SOURCE = 'summarization';
97
+
98
+ type LLMResultMessages = Parameters<
99
+ NonNullable<BaseCallbackHandler['handleChatModelStart']>
100
+ >[1];
101
+
102
+ function numberField(value: unknown, key: string): number | undefined {
103
+ if (!value || typeof value !== 'object' || !(key in value)) return undefined;
104
+ const n = Reflect.get(value, key);
105
+ return typeof n === 'number' && Number.isFinite(n) ? n : undefined;
106
+ }
107
+
108
+ /** Provider usage of one call: `llmOutput.tokenUsage` (OpenAI-compatible) or the message's `usage_metadata`. */
109
+ function reportedTotalTokens(output: LLMResult): number | undefined {
110
+ const usage: unknown = output.llmOutput?.tokenUsage;
111
+ const total = numberField(usage, 'totalTokens');
112
+ if (total !== undefined) return total;
113
+ const prompt = numberField(usage, 'promptTokens');
114
+ const completion = numberField(usage, 'completionTokens');
115
+ if (prompt !== undefined && completion !== undefined)
116
+ return prompt + completion;
117
+ const generation: unknown = output.generations[0]?.[0];
118
+ const message =
119
+ generation && typeof generation === 'object' && 'message' in generation
120
+ ? generation.message
121
+ : undefined;
122
+ const meta =
123
+ message && typeof message === 'object' && 'usage_metadata' in message
124
+ ? message.usage_metadata
125
+ : undefined;
126
+ return numberField(meta, 'total_tokens');
127
+ }
128
+
129
+ class TurnBudgetHandler extends BaseCallbackHandler {
130
+ name = 'TurnBudgetHandler';
131
+ /** A refused reservation must fail the model call, not be logged and ignored. */
132
+ raiseError = true;
133
+ awaitHandlers = true;
134
+ private readonly reservations = new Map<
135
+ string,
136
+ { tokens: number; summary: boolean }
137
+ >();
138
+ /** Tool-schema estimate per tool list, for this turn (see `toolListKey`). */
139
+ private readonly toolTokens = new Map<string, number>();
140
+
141
+ constructor(private readonly options: BudgetedLlmOptions) {
142
+ super();
143
+ }
144
+
145
+ handleChatModelStart(
146
+ _llm: unknown,
147
+ messages: LLMResultMessages,
148
+ runId: string,
149
+ _parentRunId?: string,
150
+ extraParams?: Record<string, unknown>,
151
+ _tags?: string[],
152
+ metadata?: Record<string, unknown>,
153
+ ): void {
154
+ // The handler can reach the same call twice (model-level and run-level
155
+ // registration); the first registration charges it.
156
+ if (this.reservations.has(runId)) return;
157
+ const tokens = this.options.budget.reserveModel(
158
+ estimateRequestTokens(
159
+ messages,
160
+ extraParams?.invocation_params,
161
+ this.toolTokens,
162
+ ),
163
+ this.options.outputReserveTokens,
164
+ this.options.signal,
165
+ );
166
+ this.reservations.set(runId, {
167
+ tokens,
168
+ summary: metadata?.lc_source === SUMMARIZATION_SOURCE,
169
+ });
170
+ }
171
+
172
+ handleLLMEnd(output: LLMResult, runId: string): void {
173
+ const reservation = this.reservations.get(runId);
174
+ if (reservation === undefined) return;
175
+ this.reservations.delete(runId);
176
+ const reported = reportedTotalTokens(output);
177
+ if (reported !== undefined)
178
+ this.options.budget.settleModel(reservation.tokens, reported);
179
+ }
180
+
181
+ handleLLMError(_error: unknown, runId: string): void {
182
+ const reservation = this.reservations.get(runId);
183
+ this.reservations.delete(runId);
184
+ // A failed call keeps its reservation: the provider may have consumed
185
+ // the input before failing, and a retry reserves again. A failed summary
186
+ // is the exception: the summarizer swallows the failure, the turn goes on
187
+ // with its history unchanged and does not try again (summarization.ts),
188
+ // so its reservation — the whole history it was handed — is given back
189
+ // instead of starving the rest of the turn.
190
+ if (reservation?.summary)
191
+ this.options.budget.releaseModel(reservation.tokens);
192
+ }
193
+ }
194
+
195
+ function attach(model: BaseChatModel, handler: BaseCallbackHandler): void {
196
+ const existing = model.callbacks;
197
+ if (existing === undefined) {
198
+ model.callbacks = [handler];
199
+ return;
200
+ }
201
+ if (Array.isArray(existing)) {
202
+ if (!existing.includes(handler)) model.callbacks = [...existing, handler];
203
+ return;
204
+ }
205
+ const manager = existing.copy();
206
+ manager.addHandler(handler);
207
+ model.callbacks = manager;
208
+ }
209
+
210
+ export function budgetedLlm(
211
+ adapter: LlmAdapter,
212
+ options: BudgetedLlmOptions,
213
+ ): BudgetedLlm {
214
+ const callback = new TurnBudgetHandler(options);
215
+ return {
216
+ callback,
217
+ get(role: ModelRole, params?: ChatOpenAIFields): BaseChatModel {
218
+ const model = adapter.get(role, params);
219
+ attach(model, callback);
220
+ return model;
221
+ },
222
+ };
223
+ }