@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 +13 -8
  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 +310 -0
  18. package/src/channels/contract.ts +246 -0
  19. package/src/channels/turns.ts +280 -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 +170 -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 +2003 -274
  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 +504 -108
  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 +562 -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,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
+ }
@@ -0,0 +1,273 @@
1
+ import {
2
+ AmbiguousDecisionProviderError,
3
+ CAPABILITY_ROUTER_MODES,
4
+ CAPABILITY_ROUTE_DECISION_NAME,
5
+ DecisionProviderUnavailableError,
6
+ capabilityRouteDecision,
7
+ decideCapabilityRoute,
8
+ toRoutableCapabilities,
9
+ type CapabilityRouterMode,
10
+ type DecisionEvaluator,
11
+ type DecisionTraceOptions,
12
+ type RoutableCapability,
13
+ } from '@ixo/common/ai/decisions';
14
+ import type { Logger } from '../plugin-api/types';
15
+ import { unmetRequirements } from './manifest';
16
+ import type { RegisteredManifest } from './registries';
17
+
18
+ /**
19
+ * Predicts, before the first model call of a turn, which on-demand plugin the
20
+ * user's message needs and preloads that plugin's tools for the turn, so the
21
+ * model does not spend a round trip on `load_capability`.
22
+ *
23
+ * The preload is one-turn state only: it is admitted by the capability gate
24
+ * and visible as loaded to tool handlers, but never written to the graph's
25
+ * `loadedPlugins` channel. The router fails open — any evaluation failure
26
+ * preloads nothing and the turn proceeds exactly as without a router.
27
+ */
28
+ export interface CapabilityRouterOptions {
29
+ evaluator: DecisionEvaluator;
30
+ logger: Logger;
31
+ /**
32
+ * Keeps a shadow evaluation alive after the turn has moved on
33
+ * (`ctx.waitUntil` on Workers). Without it the promise is merely detached.
34
+ */
35
+ background?: (work: Promise<unknown>) => void;
36
+ now?: () => number;
37
+ }
38
+
39
+ export interface CapabilityRouteTurn {
40
+ mode: CapabilityRouterMode;
41
+ /** Every registered manifest; the router picks the routable candidates. */
42
+ manifests: readonly RegisteredManifest[];
43
+ /** Plugins the thread has already loaded (the checkpointed channel). */
44
+ loaded: ReadonlySet<string>;
45
+ /**
46
+ * Plugins the user's delegation leaves with nothing to use (all of their
47
+ * tools admin-plane and not granted): never offered to the router. A
48
+ * function is called only when the router actually evaluates (not when
49
+ * it is off, or the message is empty), so the host does not compute the
50
+ * set for nothing.
51
+ */
52
+ hidden?: ReadonlySet<string> | (() => Promise<ReadonlySet<string>>);
53
+ /**
54
+ * The user's delegation, as `ctx.ucan.hasCapability`: a plugin whose
55
+ * `manifest.requires` it does not grant is never offered (the gate would
56
+ * refuse every one of its tools). Omitted, `requires` is not checked.
57
+ */
58
+ hasCapability?: (resource: string, action: string) => boolean;
59
+ /** The user's message text for this turn. */
60
+ text: string;
61
+ requestId: string;
62
+ /** The turn's abort signal; only an awaited (`on`) evaluation is bound to it. */
63
+ signal?: AbortSignal;
64
+ /**
65
+ * The turn's tracer and trace metadata. The router runs before the graph,
66
+ * outside any LangChain run, so without these its Decision is not traced.
67
+ */
68
+ trace?: DecisionTraceOptions;
69
+ /**
70
+ * Shadow mode only: receives what the router would have preloaded once its
71
+ * evaluation completes, so the host can compare it with what the turn
72
+ * actually loaded.
73
+ */
74
+ onShadowVerdict?: (wouldPreload: readonly string[]) => void;
75
+ }
76
+
77
+ export type CapabilityRouter = (
78
+ turn: CapabilityRouteTurn,
79
+ ) => Promise<ReadonlySet<string>>;
80
+
81
+ const NOTHING: ReadonlySet<string> = new Set<string>();
82
+
83
+ /** Narrows an env value to a router mode; anything else is `off`. */
84
+ export function capabilityRouterMode(value: unknown): CapabilityRouterMode {
85
+ return CAPABILITY_ROUTER_MODES.find((mode) => mode === value) ?? 'off';
86
+ }
87
+
88
+ /**
89
+ * The manifests the router may choose from: effectively on-demand plugins the
90
+ * thread has not loaded yet. `always` plugins are already bound and `silent`
91
+ * ones are never surfaced, so predicting them is pointless; a `hidden` one
92
+ * (no tool the user's delegation reaches), or one whose `requires` the
93
+ * delegation (`hasCapability`) does not grant, is not even named to the
94
+ * router.
95
+ */
96
+ export function routableCandidates(
97
+ manifests: readonly RegisteredManifest[],
98
+ loaded: ReadonlySet<string>,
99
+ hidden: ReadonlySet<string> = NOTHING,
100
+ hasCapability?: (resource: string, action: string) => boolean,
101
+ ): RoutableCapability[] {
102
+ return toRoutableCapabilities(
103
+ manifests
104
+ .filter(
105
+ ({ pluginName, manifest }) =>
106
+ (manifest.visibility ?? 'on-demand') === 'on-demand' &&
107
+ !loaded.has(pluginName) &&
108
+ !hidden.has(pluginName) &&
109
+ (hasCapability === undefined ||
110
+ unmetRequirements(manifest, hasCapability).length === 0),
111
+ )
112
+ .map(({ pluginName, manifest }) => ({
113
+ name: pluginName,
114
+ title: manifest.title,
115
+ summary: manifest.summary,
116
+ })),
117
+ );
118
+ }
119
+
120
+ /**
121
+ * After-turn comparison for shadow mode: which plugins the turn loaded on its
122
+ * own, and whether that matches what the router would have preloaded.
123
+ */
124
+ export function shadowAgreement(args: {
125
+ priorLoaded: ReadonlySet<string>;
126
+ loadedAfter: ReadonlySet<string>;
127
+ wouldPreload: readonly string[];
128
+ }): { loadedDuringTurn: string[]; agree: boolean } {
129
+ const loadedDuringTurn = Array.from(args.loadedAfter).filter(
130
+ (name) => !args.priorLoaded.has(name),
131
+ );
132
+ const predicted = new Set(args.wouldPreload);
133
+ const agree =
134
+ predicted.size === loadedDuringTurn.length &&
135
+ loadedDuringTurn.every((name) => predicted.has(name));
136
+ return { loadedDuringTurn, agree };
137
+ }
138
+
139
+ function errorName(error: unknown): string {
140
+ return error instanceof Error ? error.name : typeof error;
141
+ }
142
+
143
+ function list(names: readonly string[]): string {
144
+ return `[${names.join(', ')}]`;
145
+ }
146
+
147
+ export function createCapabilityRouter(
148
+ options: CapabilityRouterOptions,
149
+ ): CapabilityRouter {
150
+ const { evaluator, logger } = options;
151
+ const now = options.now ?? (() => Date.now());
152
+ const background =
153
+ options.background ??
154
+ ((work: Promise<unknown>) => {
155
+ void work;
156
+ });
157
+ // A missing Decision provider is a deployment fact, not a per-turn event:
158
+ // said once per object, then the router stays quiet.
159
+ let unavailableWarned = false;
160
+
161
+ // An ambiguous multi-provider configuration is a subclass: equally a
162
+ // deployment fact, but fixed by routing rather than by adding a provider.
163
+ const unavailable = (
164
+ error: unknown,
165
+ ): error is DecisionProviderUnavailableError =>
166
+ error instanceof DecisionProviderUnavailableError;
167
+
168
+ const warnUnavailable = (
169
+ requestId: string,
170
+ mode: CapabilityRouterMode,
171
+ error: DecisionProviderUnavailableError,
172
+ ) => {
173
+ if (unavailableWarned) return;
174
+ unavailableWarned = true;
175
+ const hint =
176
+ error instanceof AmbiguousDecisionProviderError
177
+ ? `route ${CAPABILITY_ROUTE_DECISION_NAME} or set decisionProviderPolicy.defaultProviderId`
178
+ : 'set DECISION_PROVIDER or CAPABILITY_ROUTER=off';
179
+ logger.warn(
180
+ `[capability-router] request=${requestId} mode=${mode} status=fallback reason=${error.name} (${hint})`,
181
+ );
182
+ };
183
+
184
+ return async (turn) => {
185
+ if (turn.mode === 'off') return NOTHING;
186
+ const text = turn.text.trim();
187
+ if (!text) return NOTHING;
188
+ let hidden: ReadonlySet<string> | undefined;
189
+ try {
190
+ hidden =
191
+ typeof turn.hidden === 'function' ? await turn.hidden() : turn.hidden;
192
+ } catch (error) {
193
+ // Fail open like any other router failure: no preload this turn.
194
+ logger.warn(
195
+ `[capability-router] request=${turn.requestId} mode=${turn.mode} status=fallback reason=${errorName(error)} (hidden plugins unavailable)`,
196
+ );
197
+ return NOTHING;
198
+ }
199
+ const capabilities = routableCandidates(
200
+ turn.manifests,
201
+ turn.loaded,
202
+ hidden,
203
+ turn.hasCapability,
204
+ );
205
+ if (capabilities.length === 0) return NOTHING;
206
+
207
+ // The turn is prepared from a checkpoint read without its messages, so
208
+ // the router sees the current message only; `recentTurns` stays empty.
209
+ const input = { text, recentTurns: [], capabilities };
210
+ const { requestId } = turn;
211
+
212
+ if (turn.mode === 'shadow') {
213
+ const started = now();
214
+ background(
215
+ evaluator
216
+ .evaluate(capabilityRouteDecision, input, turn.trace)
217
+ .then((evaluation) => {
218
+ const verdict = decideCapabilityRoute(evaluation, capabilities);
219
+ logger.log(
220
+ `[capability-router-shadow] request=${requestId} status=ok ` +
221
+ `needsCapability=${verdict.needsCapability.toFixed(2)} ` +
222
+ `capability=${verdict.capability ?? '-'} ` +
223
+ `capabilityConfidence=${verdict.capabilityConfidence?.toFixed(2) ?? '-'} ` +
224
+ `wouldPreload=${list(verdict.preload)} reason=${verdict.reason} ` +
225
+ `latencyMs=${now() - started} provider=${evaluation.provider} model=${evaluation.model}`,
226
+ );
227
+ turn.onShadowVerdict?.(verdict.preload);
228
+ })
229
+ .catch((error: unknown) => {
230
+ if (unavailable(error)) {
231
+ warnUnavailable(requestId, 'shadow', error);
232
+ return;
233
+ }
234
+ logger.log(
235
+ `[capability-router-shadow] request=${requestId} status=failed errorType=${errorName(error)} latencyMs=${now() - started}`,
236
+ );
237
+ }),
238
+ );
239
+ return NOTHING;
240
+ }
241
+
242
+ try {
243
+ const evaluation = await evaluator.evaluate(
244
+ capabilityRouteDecision,
245
+ input,
246
+ {
247
+ ...turn.trace,
248
+ ...(turn.signal && { signal: turn.signal }),
249
+ },
250
+ );
251
+ const verdict = decideCapabilityRoute(evaluation, capabilities);
252
+ if (verdict.preload.length === 0) {
253
+ logger.debug?.(
254
+ `[capability-router] request=${requestId} mode=on status=none reason=${verdict.reason} candidates=${capabilities.length}`,
255
+ );
256
+ return NOTHING;
257
+ }
258
+ logger.log(
259
+ `[capability-router] request=${requestId} preloaded=${list(verdict.preload)} candidates=${capabilities.length}`,
260
+ );
261
+ return new Set(verdict.preload);
262
+ } catch (error) {
263
+ if (unavailable(error)) {
264
+ warnUnavailable(requestId, 'on', error);
265
+ } else {
266
+ logger.warn(
267
+ `[capability-router] request=${requestId} mode=on status=fallback reason=${errorName(error)}`,
268
+ );
269
+ }
270
+ return NOTHING;
271
+ }
272
+ };
273
+ }
@@ -3,7 +3,8 @@
3
3
  * (`context-window.ts`) so that they scale with the model:
4
4
  *
5
5
  * - `summarizeAtTokens` — the history is condensed once it is this large
6
- * (default half the window; Hermes fires at 50%);
6
+ * (default half the window; Hermes fires at 50%),
7
+ * never above what the summarizer may read;
7
8
  * - `pruneAtTokens` — above this, old tool results are demoted to
8
9
  * one-liners in the request (no model call, no
9
10
  * state change) before the model sees them;
@@ -64,8 +65,14 @@ export interface ContextBudget {
64
65
  resultCapChars: number;
65
66
  requestCapTokens: number;
66
67
  outputReserveTokens: number;
67
- /** The summarizer's own input limit (what it may read to write the summary). */
68
+ /**
69
+ * The summarizer's own input limit (what it may read to write the summary):
70
+ * the smaller of what the main model's request cap and the summarizing
71
+ * model's own request cap leave, less a margin for the summary prompt.
72
+ */
68
73
  summaryInputTokens: number;
74
+ /** The summarizing model's window, when the host resolved it. */
75
+ summaryWindowTokens?: number;
69
76
  summarizeTriggerMessages?: number;
70
77
  keepMessages: number;
71
78
  }
@@ -142,12 +149,14 @@ export function contextKnobs(
142
149
  };
143
150
  }
144
151
 
145
- /** The budget for a turn on `resolution.model`. */
146
- export function contextBudgetFor(
147
- resolution: ContextWindowResolution,
148
- knobs: ContextKnobs = DEFAULT_CONTEXT_KNOBS,
149
- ): ContextBudget {
150
- const window = resolution.tokens;
152
+ /** Tokens the summary prompt and its framing need on top of the history it reads. */
153
+ const SUMMARY_PROMPT_MARGIN_TOKENS = 4_000;
154
+
155
+ /** What a request to a model with `window` may hold, the reply reserve left out. */
156
+ function requestCapFor(
157
+ window: number,
158
+ knobs: ContextKnobs,
159
+ ): { requestCapTokens: number; outputReserveTokens: number } {
151
160
  // The reply reserve can never eat a small window: cap it at a quarter.
152
161
  const outputReserveTokens = Math.min(
153
162
  knobs.outputReserveTokens,
@@ -157,9 +166,42 @@ export function contextBudgetFor(
157
166
  1_000,
158
167
  Math.floor(window * knobs.requestFraction) - outputReserveTokens,
159
168
  );
169
+ return { requestCapTokens, outputReserveTokens };
170
+ }
171
+
172
+ /**
173
+ * The budget for a turn on `resolution.model`. `summarizer` is the window of
174
+ * the model that writes the summary (the `routing` role), resolved the same
175
+ * way: the history it is handed is bounded by its own window too, not only
176
+ * by the main model's. Omitted, the main model's window alone bounds it.
177
+ */
178
+ export function contextBudgetFor(
179
+ resolution: ContextWindowResolution,
180
+ knobs: ContextKnobs = DEFAULT_CONTEXT_KNOBS,
181
+ summarizer?: ContextWindowResolution,
182
+ ): ContextBudget {
183
+ const window = resolution.tokens;
184
+ const { requestCapTokens, outputReserveTokens } = requestCapFor(
185
+ window,
186
+ knobs,
187
+ );
188
+ const summaryRequestCap = summarizer
189
+ ? Math.min(
190
+ requestCapTokens,
191
+ requestCapFor(summarizer.tokens, knobs).requestCapTokens,
192
+ )
193
+ : requestCapTokens;
194
+ const summaryInputTokens = Math.max(
195
+ 1_000,
196
+ summaryRequestCap - SUMMARY_PROMPT_MARGIN_TOKENS,
197
+ );
198
+ // The summarizer keeps only the newest `summaryInputTokens` of what it is
199
+ // asked to condense and drops the rest unread — the earlier summary first.
200
+ // Summarizing starts no later than that, so nothing is lost unsummarized.
160
201
  const summarizeAtTokens = Math.min(
161
202
  Math.floor(window * knobs.summarizeFraction),
162
203
  requestCapTokens,
204
+ summaryInputTokens,
163
205
  );
164
206
  const pruneAtTokens = Math.min(
165
207
  Math.floor(window * knobs.pruneFraction),
@@ -177,7 +219,8 @@ export function contextBudgetFor(
177
219
  ),
178
220
  requestCapTokens,
179
221
  outputReserveTokens,
180
- summaryInputTokens: Math.max(1_000, requestCapTokens - 4_000),
222
+ summaryInputTokens,
223
+ ...(summarizer ? { summaryWindowTokens: summarizer.tokens } : {}),
181
224
  ...(knobs.summarizeTriggerMessages !== undefined
182
225
  ? { summarizeTriggerMessages: knobs.summarizeTriggerMessages }
183
226
  : {}),
@@ -196,6 +239,50 @@ export function estimateContentTokens(content: unknown): number {
196
239
  }
197
240
  }
198
241
 
242
+ /**
243
+ * Per-message memo of the chars/4 estimates: every model step of a turn
244
+ * re-estimates the whole history (the context guard, the turn budget, the
245
+ * summarizer's trigger), and the history is the same message objects from
246
+ * step to step. An entry is reused only while the message still holds the
247
+ * same `content` / `tool_calls` values it was computed from.
248
+ */
249
+ interface MessageEstimate {
250
+ content: unknown;
251
+ contentTokens: number;
252
+ toolCalls?: { value: unknown; tokens: number };
253
+ }
254
+
255
+ const messageEstimates = new WeakMap<object, MessageEstimate>();
256
+
257
+ function estimateOf(message: { content: unknown }): MessageEstimate {
258
+ const cached = messageEstimates.get(message);
259
+ if (cached && cached.content === message.content) return cached;
260
+ const fresh: MessageEstimate = {
261
+ content: message.content,
262
+ contentTokens: estimateContentTokens(message.content),
263
+ };
264
+ messageEstimates.set(message, fresh);
265
+ return fresh;
266
+ }
267
+
268
+ /** `estimateContentTokens(message.content)`, memoised per message. */
269
+ export function messageContentTokens(message: { content: unknown }): number {
270
+ return estimateOf(message).contentTokens;
271
+ }
272
+
273
+ /** `estimateContentTokens(toolCalls)` for `message`'s tool calls, memoised per message. */
274
+ export function messageToolCallTokens(
275
+ message: { content: unknown },
276
+ toolCalls: unknown,
277
+ ): number {
278
+ const entry = estimateOf(message);
279
+ if (entry.toolCalls && entry.toolCalls.value === toolCalls)
280
+ return entry.toolCalls.tokens;
281
+ const tokens = estimateContentTokens(toolCalls);
282
+ entry.toolCalls = { value: toolCalls, tokens };
283
+ return tokens;
284
+ }
285
+
199
286
  export function describeBudget(b: ContextBudget): string {
200
- return `model=${b.model} window=${b.windowTokens} (${b.origin}) summarizeAt=${b.summarizeAtTokens} pruneAt=${b.pruneAtTokens} resultCap=${b.resultCapChars}c requestCap=${b.requestCapTokens} reserve=${b.outputReserveTokens}`;
287
+ return `model=${b.model} window=${b.windowTokens} (${b.origin}) summarizeAt=${b.summarizeAtTokens} pruneAt=${b.pruneAtTokens} resultCap=${b.resultCapChars}c requestCap=${b.requestCapTokens} reserve=${b.outputReserveTokens} summaryInput=${b.summaryInputTokens}${b.summaryWindowTokens !== undefined ? ` (summarizer window ${b.summaryWindowTokens})` : ''}`;
201
288
  }