@ixo/oracle-runtime-workers 0.37.2 → 0.39.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 (111) hide show
  1. package/package.json +4 -4
  2. package/src/attachments/channel-links.ts +74 -7
  3. package/src/attachments/channel.ts +15 -4
  4. package/src/attachments/content-blocks.ts +19 -16
  5. package/src/attachments/download.ts +173 -50
  6. package/src/attachments/index.ts +6 -5
  7. package/src/attachments/pipeline.ts +117 -49
  8. package/src/attachments/resize.ts +142 -0
  9. package/src/attachments/retention.ts +26 -70
  10. package/src/attachments/room-media.ts +643 -0
  11. package/src/attachments/route.ts +47 -4
  12. package/src/attachments/sandbox-archive.ts +15 -4
  13. package/src/attachments/sanitize.ts +24 -13
  14. package/src/attachments/view.ts +82 -38
  15. package/src/channels/replies.ts +8 -4
  16. package/src/core/capability-expiry.ts +62 -0
  17. package/src/core/capability-router.ts +126 -95
  18. package/src/core/context-budget.ts +116 -4
  19. package/src/core/domain-context/document.ts +74 -2
  20. package/src/core/domain-context/index.ts +1 -0
  21. package/src/core/domain-context/resolver.ts +9 -6
  22. package/src/core/domain-context/turn-context.ts +62 -13
  23. package/src/core/domain-context/types.ts +17 -0
  24. package/src/core/env.ts +21 -2
  25. package/src/core/index.ts +4 -0
  26. package/src/core/llm.ts +29 -0
  27. package/src/core/main-agent-types.ts +53 -4
  28. package/src/core/main-agent.ts +228 -125
  29. package/src/core/manifest.ts +109 -24
  30. package/src/core/mcp-result.ts +56 -0
  31. package/src/core/meta-tools.ts +258 -110
  32. package/src/core/middlewares/attachment-recall.ts +502 -0
  33. package/src/core/middlewares/capability-gate.ts +77 -28
  34. package/src/core/middlewares/context-guard.ts +154 -21
  35. package/src/core/middlewares/index.ts +5 -0
  36. package/src/core/middlewares/result-cap.ts +1 -1
  37. package/src/core/middlewares/summarization.ts +9 -0
  38. package/src/core/middlewares/tool-marks.ts +11 -2
  39. package/src/core/middlewares/turn-boundary.ts +22 -12
  40. package/src/core/operating-guides.ts +126 -0
  41. package/src/core/plugins/skills/skills-tools.ts +6 -31
  42. package/src/core/plugins/skills/skills.plugin.ts +4 -3
  43. package/src/core/plugins/weather/weather.plugin.ts +1 -0
  44. package/src/core/prompt-composer.ts +188 -111
  45. package/src/core/read-result-tool.ts +48 -14
  46. package/src/core/registries.ts +13 -5
  47. package/src/core/state.ts +4 -3
  48. package/src/core/sub-agent-fallback.ts +1 -0
  49. package/src/core/subagent-as-tool.ts +4 -1
  50. package/src/core/tool-access.ts +3 -32
  51. package/src/core/wrap-plugin-tool.ts +2 -1
  52. package/src/do/contracts.ts +64 -24
  53. package/src/do/room-mirror.ts +144 -20
  54. package/src/do/transcript.ts +41 -12
  55. package/src/do/turn-attachments.ts +45 -6
  56. package/src/do/turn-metadata.ts +16 -4
  57. package/src/do/user-oracle-do.ts +639 -152
  58. package/src/index.ts +1 -0
  59. package/src/llm/provider-error.ts +5 -2
  60. package/src/llm/stream-liveness.ts +3 -2
  61. package/src/matrix/gateway-do.ts +164 -102
  62. package/src/matrix/ingest.ts +12 -3
  63. package/src/matrix/media-deadline.ts +5 -47
  64. package/src/memory/session-history-indexer.ts +60 -14
  65. package/src/plugin-api/oracle-plugin.ts +4 -5
  66. package/src/plugin-api/types.ts +32 -3
  67. package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
  68. package/src/plugins/agui/agui.plugin.ts +1 -0
  69. package/src/plugins/attachments/attachments.plugin.ts +3 -3
  70. package/src/plugins/attachments/view-attachment-tool.ts +6 -4
  71. package/src/plugins/compact-schema.ts +80 -0
  72. package/src/plugins/composio/composio.plugin.ts +1 -0
  73. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
  74. package/src/plugins/editor/editor.plugin.ts +4 -0
  75. package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
  76. package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
  77. package/src/plugins/flows/flows.plugin.ts +37 -6
  78. package/src/plugins/flows/prompts.ts +1 -1
  79. package/src/plugins/flows/tools/authoring.ts +5 -7
  80. package/src/plugins/flows/tools/discovery.ts +1 -4
  81. package/src/plugins/flows/tools/forms.ts +2 -8
  82. package/src/plugins/flows/tools/linkage.ts +1 -4
  83. package/src/plugins/flows/tools/settings.ts +1 -4
  84. package/src/plugins/flows/translator.ts +16 -5
  85. package/src/plugins/flows/types.ts +27 -48
  86. package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
  87. package/src/plugins/index.ts +2 -2
  88. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
  89. package/src/plugins/ixo-chain/kinds/core.ts +2 -2
  90. package/src/plugins/ixo-chain/kinds/types.ts +34 -5
  91. package/src/plugins/ixo-chain/queries.ts +10 -1
  92. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
  93. package/src/plugins/matrix-group-chats/index.ts +1 -0
  94. package/src/plugins/memory/memory-tools.ts +4 -4
  95. package/src/plugins/memory/memory.plugin.ts +27 -1
  96. package/src/plugins/memory/oracle-document-tools.ts +24 -49
  97. package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
  98. package/src/plugins/portal/portal.plugin.ts +1 -0
  99. package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
  100. package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
  101. package/src/plugins/tasks/manifest.ts +1 -0
  102. package/src/plugins/tasks/tasks-tools.ts +14 -19
  103. package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
  104. package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
  105. package/src/plugins/vfs/vfs-tools.ts +6 -6
  106. package/src/plugins/vfs/vfs.plugin.ts +4 -5
  107. package/src/plugins/yoma/yoma.plugin.ts +1 -0
  108. package/src/sqlite/inline-payloads.ts +121 -0
  109. package/src/sqlite/serialization.ts +12 -0
  110. package/src/sqlite/sqlite-saver.ts +258 -42
  111. package/src/tasks/schedule-input.ts +5 -22
@@ -1,29 +1,36 @@
1
1
  import {
2
2
  AmbiguousDecisionProviderError,
3
+ CAPABILITY_PRELOAD_BATCH_SIZE,
4
+ CAPABILITY_PRELOAD_WARM_THRESHOLD,
3
5
  CAPABILITY_ROUTER_MODES,
4
6
  CAPABILITY_ROUTE_DECISION_NAME,
5
7
  DecisionProviderUnavailableError,
6
- capabilityRouteDecision,
7
- decideCapabilityRoute,
8
+ capabilityPreloadDecision,
9
+ decideCapabilityPreload,
8
10
  toRoutableCapabilities,
11
+ type CapabilityPreloadVerdict,
9
12
  type CapabilityRouterMode,
13
+ type DecisionEvaluateOptions,
10
14
  type DecisionEvaluator,
11
15
  type DecisionTraceOptions,
12
16
  type RoutableCapability,
13
17
  } from '@ixo/common/ai/decisions';
14
- import type { Logger } from '../plugin-api/types';
15
- import { unmetRequirements } from './manifest';
18
+ import type { Logger, PluginManifest } from '../plugin-api/types';
16
19
  import type { RegisteredManifest } from './registries';
17
20
 
18
21
  /**
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
+ * Predicts, before the first model call of a turn, every plugin the user's
23
+ * message needs from what is left to load, so the model does not spend a
24
+ * round trip on `load_capability`. A plugin it misses is still one
25
+ * `load_capability` away.
22
26
  *
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
+ * A preload is a normal load: the turn writes it to the graph's
28
+ * `loadedPlugins` channel and it stays until a cold turn unloads it, so the
29
+ * tool list changes once. It costs the cache miss a `load_capability` would,
30
+ * without the extra call. On a turn the provider's cache has lost anyway it
31
+ * costs no miss at all, which is why a warm turn asks for a higher score
32
+ * (`CAPABILITY_ROUTER_WARM_THRESHOLD`). The router fails open — any evaluation
33
+ * failure preloads nothing and the turn proceeds exactly as without a router.
27
34
  */
28
35
  export interface CapabilityRouterOptions {
29
36
  evaluator: DecisionEvaluator;
@@ -38,26 +45,23 @@ export interface CapabilityRouterOptions {
38
45
 
39
46
  export interface CapabilityRouteTurn {
40
47
  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
48
  /**
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.
49
+ * What a `load_capability` could load right now (`createMainAgent` works
50
+ * this out from the turn's own tools): the plugins with an on-demand tool
51
+ * the thread has not loaded, `always` ones with `onDemandTools` included,
52
+ * that the user's delegation may use.
51
53
  */
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;
54
+ candidates: readonly RegisteredManifest[];
55
+ /** The score a preload needs; the calibration profile's when omitted. */
56
+ threshold?: number;
59
57
  /** The user's message text for this turn. */
60
58
  text: string;
59
+ /**
60
+ * The thread's latest visible turns, oldest first, so a follow-up such as
61
+ * "send half to Bob" can be routed. Called only when the router evaluates;
62
+ * when it fails the router evaluates the message alone.
63
+ */
64
+ recentTurns?: () => Promise<readonly string[]>;
61
65
  requestId: string;
62
66
  /** The turn's abort signal; only an awaited (`on`) evaluation is bound to it. */
63
67
  signal?: AbortSignal;
@@ -85,35 +89,43 @@ export function capabilityRouterMode(value: unknown): CapabilityRouterMode {
85
89
  return CAPABILITY_ROUTER_MODES.find((mode) => mode === value) ?? 'off';
86
90
  }
87
91
 
92
+ /** The score a preload needs this turn: the operator's, else the default. */
93
+ export function preloadThreshold(
94
+ cold: boolean,
95
+ env: {
96
+ CAPABILITY_ROUTER_COLD_THRESHOLD?: unknown;
97
+ CAPABILITY_ROUTER_WARM_THRESHOLD?: unknown;
98
+ },
99
+ ): { threshold?: number } {
100
+ const set = cold
101
+ ? env.CAPABILITY_ROUTER_COLD_THRESHOLD
102
+ : env.CAPABILITY_ROUTER_WARM_THRESHOLD;
103
+ if (typeof set === 'number') return { threshold: set };
104
+ return cold ? {} : { threshold: CAPABILITY_PRELOAD_WARM_THRESHOLD };
105
+ }
106
+
88
107
  /**
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.
108
+ * What the router reads about a plugin: its summary and the `whenToUse` lines
109
+ * the manifest already carries. The summary alone often leaves out what a
110
+ * plugin does ("retire tokens", "cancel a reminder"), and a yes/no question
111
+ * then scores far below the preload threshold.
95
112
  */
113
+ function routingSummary(manifest: PluginManifest): string {
114
+ return manifest.whenToUse.length > 0
115
+ ? `${manifest.summary} Use when: ${manifest.whenToUse.join(' ')}`
116
+ : manifest.summary;
117
+ }
118
+
119
+ /** The Decision's view of the candidates. */
96
120
  export function routableCandidates(
97
- manifests: readonly RegisteredManifest[],
98
- loaded: ReadonlySet<string>,
99
- hidden: ReadonlySet<string> = NOTHING,
100
- hasCapability?: (resource: string, action: string) => boolean,
121
+ candidates: readonly RegisteredManifest[],
101
122
  ): RoutableCapability[] {
102
123
  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
- })),
124
+ candidates.map(({ pluginName, manifest }) => ({
125
+ name: pluginName,
126
+ title: manifest.title,
127
+ summary: routingSummary(manifest),
128
+ })),
117
129
  );
118
130
  }
119
131
 
@@ -144,6 +156,23 @@ function list(names: readonly string[]): string {
144
156
  return `[${names.join(', ')}]`;
145
157
  }
146
158
 
159
+ /** The likeliest candidates, for refitting the threshold from real traffic. */
160
+ function topScores(verdict: CapabilityPreloadVerdict): string {
161
+ return verdict.scores
162
+ .slice(0, 3)
163
+ .map(({ name, probability }) => `${name}:${probability.toFixed(2)}`)
164
+ .join(',');
165
+ }
166
+
167
+ /** Splits candidates into lists one Decision request can carry. */
168
+ function batches<T>(items: readonly T[], size: number): T[][] {
169
+ const chunks: T[][] = [];
170
+ for (let start = 0; start < items.length; start += size) {
171
+ chunks.push(items.slice(start, start + size));
172
+ }
173
+ return chunks;
174
+ }
175
+
147
176
  export function createCapabilityRouter(
148
177
  options: CapabilityRouterOptions,
149
178
  ): CapabilityRouter {
@@ -183,47 +212,51 @@ export function createCapabilityRouter(
183
212
 
184
213
  return async (turn) => {
185
214
  if (turn.mode === 'off') return NOTHING;
215
+ // No score reaches it: nothing to spend a Decision on.
216
+ if ((turn.threshold ?? 0) > 1) return NOTHING;
186
217
  const text = turn.text.trim();
187
218
  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
- );
219
+ const capabilities = routableCandidates(turn.candidates);
205
220
  if (capabilities.length === 0) return NOTHING;
206
221
 
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
222
  const { requestId } = turn;
223
+ let recentTurns: string[] = [];
224
+ const evaluateAll = async (options?: DecisionEvaluateOptions) => {
225
+ try {
226
+ recentTurns = [...((await turn.recentTurns?.()) ?? [])];
227
+ } catch (error) {
228
+ logger.warn(
229
+ `[capability-router] request=${requestId} mode=${turn.mode} status=degraded reason=${errorName(error)} (recent turns unavailable)`,
230
+ );
231
+ }
232
+ return Promise.all(
233
+ batches(capabilities, CAPABILITY_PRELOAD_BATCH_SIZE).map((batch) =>
234
+ evaluator.evaluate(
235
+ capabilityPreloadDecision,
236
+ { text, recentTurns, capabilities: batch },
237
+ options,
238
+ ),
239
+ ),
240
+ );
241
+ };
211
242
 
212
243
  if (turn.mode === 'shadow') {
213
244
  const started = now();
214
245
  background(
215
- evaluator
216
- .evaluate(capabilityRouteDecision, input, turn.trace)
217
- .then((evaluation) => {
218
- const verdict = decideCapabilityRoute(evaluation, capabilities);
246
+ evaluateAll(turn.trace)
247
+ .then((evaluations) => {
248
+ const verdict = decideCapabilityPreload(
249
+ evaluations,
250
+ capabilities,
251
+ turn.threshold,
252
+ );
219
253
  logger.log(
220
254
  `[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} ` +
255
+ `top=${topScores(verdict)} ` +
256
+ `wouldPreload=${list(verdict.preload)} ` +
257
+ `threshold=${turn.threshold ?? 'profile'} ` +
225
258
  `calibrationProfile=${verdict.calibrationProfileRef ?? '-'} ` +
226
- `latencyMs=${now() - started} provider=${evaluation.provider} model=${evaluation.model}`,
259
+ `latencyMs=${now() - started} provider=${evaluations[0]?.provider} model=${evaluations[0]?.model}`,
227
260
  );
228
261
  turn.onShadowVerdict?.(verdict.preload);
229
262
  })
@@ -240,25 +273,23 @@ export function createCapabilityRouter(
240
273
  return NOTHING;
241
274
  }
242
275
 
276
+ const started = now();
243
277
  try {
244
- const evaluation = await evaluator.evaluate(
245
- capabilityRouteDecision,
246
- input,
247
- {
248
- ...turn.trace,
249
- ...(turn.signal && { signal: turn.signal }),
250
- },
278
+ const evaluations = await evaluateAll({
279
+ ...turn.trace,
280
+ ...(turn.signal && { signal: turn.signal }),
281
+ });
282
+ const verdict = decideCapabilityPreload(
283
+ evaluations,
284
+ capabilities,
285
+ turn.threshold,
251
286
  );
252
- const verdict = decideCapabilityRoute(evaluation, capabilities);
253
- if (verdict.preload.length === 0) {
254
- logger.debug?.(
255
- `[capability-router] request=${requestId} mode=on status=none reason=${verdict.reason} candidates=${capabilities.length}`,
256
- );
257
- return NOTHING;
258
- }
259
287
  logger.log(
260
- `[capability-router] request=${requestId} preloaded=${list(verdict.preload)} candidates=${capabilities.length}`,
288
+ `[capability-router] request=${requestId} mode=on status=${verdict.preload.length > 0 ? 'ok' : 'none'} ` +
289
+ `preloaded=${list(verdict.preload)} top=${topScores(verdict)} threshold=${turn.threshold ?? 'profile'} ` +
290
+ `candidates=${capabilities.length} recentTurns=${recentTurns.length} latencyMs=${now() - started}`,
261
291
  );
292
+ if (verdict.preload.length === 0) return NOTHING;
262
293
  return new Set(verdict.preload);
263
294
  } catch (error) {
264
295
  if (unavailable(error)) {
@@ -12,12 +12,23 @@
12
12
  * - `pruneAtTokens` — above this, old tool results are demoted to
13
13
  * one-liners in the request (no model call, no
14
14
  * state change) before the model sees them;
15
+ * - `staleResultTurns` — on a turn that opens after `cacheColdMs` of
16
+ * quiet, tool results older than this many turns
17
+ * leave the request (`read_result` reads them
18
+ * back). Whatever the window: this is about what
19
+ * a call costs, not about what fits;
15
20
  * - `resultCapChars` — a tool result larger than this is stored whole
16
21
  * and the model sees head + tail + a handle;
17
22
  * - `requestCapTokens` — a request that is still larger than this after
18
23
  * pruning is refused instead of sent to fail;
19
24
  * - `outputReserveTokens`— the room left for the reply.
20
25
  *
26
+ * A large window says nothing about what a request should cost, so the
27
+ * fractions are bounded in tokens too (`summarizeMaxTokens`, `pruneMaxTokens`,
28
+ * `resultCapMaxChars`): a million-token window would otherwise never condense
29
+ * a thread that long ago stopped being worth re-sending, and would pass the
30
+ * 272k-token point where providers such as OpenAI double the price.
31
+ *
21
32
  * Token counts are chars/4 estimates (`estimateTokensApprox`), never billing.
22
33
  */
23
34
  import type { Logger } from '../plugin-api/types';
@@ -30,12 +41,34 @@ export const CHARS_PER_TOKEN = 4;
30
41
  export interface ContextKnobs {
31
42
  /** Fraction of the window at which the history is summarized (default 0.6). */
32
43
  summarizeFraction: number;
44
+ /** Ceiling on the summarize threshold in tokens, whatever the window (default 200,000). */
45
+ summarizeMaxTokens: number;
33
46
  /** Fraction of the window above which old tool results are pruned per request (default 0.35). */
34
47
  pruneFraction: number;
48
+ /** Ceiling on the prune threshold in tokens, whatever the window (default 100,000). */
49
+ pruneMaxTokens: number;
50
+ /**
51
+ * Finished turns whose tool results stay whole when a cold turn trims the
52
+ * older ones out of the request (default 2; 0 never trims).
53
+ */
54
+ staleResultTurns: number;
55
+ /**
56
+ * Quiet time after which a turn counts as cold: the provider's prompt
57
+ * cache is gone, so changing the prefix costs nothing. Default 2,100,000
58
+ * (35 minutes): on `gpt-5.6-luna`, Bedrock and OpenAI routes alike, a prefix
59
+ * left idle for 20 minutes still hit and one left for 35 missed. 0 makes
60
+ * every turn cold.
61
+ */
62
+ cacheColdMs: number;
63
+ /**
64
+ * Turns an on-demand capability stays loaded without being used (default
65
+ * 3). It is unloaded on the first cold turn after that; 0 never unloads.
66
+ */
67
+ pluginIdleTurns: number;
35
68
  /** Fraction of the window one tool result may occupy before it is capped (default 0.12). */
36
69
  resultCapFraction: number;
37
70
  /**
38
- * Ceiling on the result cap in chars (default 200,000 ≈ 50k tokens): a
71
+ * Ceiling on the result cap in chars (default 64,000 ≈ 16k tokens): a
39
72
  * million-token window does not make a half-megabyte tool result a good
40
73
  * idea — the rest stays reachable through `read_result`.
41
74
  */
@@ -52,9 +85,14 @@ export interface ContextKnobs {
52
85
 
53
86
  export const DEFAULT_CONTEXT_KNOBS: ContextKnobs = {
54
87
  summarizeFraction: 0.6,
88
+ summarizeMaxTokens: 200_000,
55
89
  pruneFraction: 0.35,
90
+ pruneMaxTokens: 100_000,
91
+ staleResultTurns: 2,
92
+ cacheColdMs: 2_100_000,
93
+ pluginIdleTurns: 3,
56
94
  resultCapFraction: 0.12,
57
- resultCapMaxChars: 200_000,
95
+ resultCapMaxChars: 64_000,
58
96
  requestFraction: 0.95,
59
97
  outputReserveTokens: 8_000,
60
98
  keepMessages: 10,
@@ -66,6 +104,9 @@ export interface ContextBudget {
66
104
  origin: ContextWindowResolution['origin'];
67
105
  summarizeAtTokens: number;
68
106
  pruneAtTokens: number;
107
+ staleResultTurns: number;
108
+ cacheColdMs: number;
109
+ pluginIdleTurns: number;
69
110
  resultCapChars: number;
70
111
  requestCapTokens: number;
71
112
  outputReserveTokens: number;
@@ -118,10 +159,37 @@ export function contextKnobs(
118
159
  'CONTEXT_SUMMARIZE_FRACTION',
119
160
  DEFAULT_CONTEXT_KNOBS.summarizeFraction,
120
161
  ),
162
+ summarizeMaxTokens:
163
+ int(
164
+ 'CONTEXT_SUMMARIZE_MAX_TOKENS',
165
+ DEFAULT_CONTEXT_KNOBS.summarizeMaxTokens,
166
+ 1_000,
167
+ ) ?? DEFAULT_CONTEXT_KNOBS.summarizeMaxTokens,
121
168
  pruneFraction: fraction(
122
169
  'CONTEXT_PRUNE_FRACTION',
123
170
  DEFAULT_CONTEXT_KNOBS.pruneFraction,
124
171
  ),
172
+ pruneMaxTokens:
173
+ int(
174
+ 'CONTEXT_PRUNE_MAX_TOKENS',
175
+ DEFAULT_CONTEXT_KNOBS.pruneMaxTokens,
176
+ 1_000,
177
+ ) ?? DEFAULT_CONTEXT_KNOBS.pruneMaxTokens,
178
+ staleResultTurns:
179
+ int(
180
+ 'CONTEXT_STALE_RESULT_TURNS',
181
+ DEFAULT_CONTEXT_KNOBS.staleResultTurns,
182
+ 0,
183
+ ) ?? DEFAULT_CONTEXT_KNOBS.staleResultTurns,
184
+ cacheColdMs:
185
+ int('CONTEXT_CACHE_COLD_MS', DEFAULT_CONTEXT_KNOBS.cacheColdMs, 0) ??
186
+ DEFAULT_CONTEXT_KNOBS.cacheColdMs,
187
+ pluginIdleTurns:
188
+ int(
189
+ 'CONTEXT_PLUGIN_IDLE_TURNS',
190
+ DEFAULT_CONTEXT_KNOBS.pluginIdleTurns,
191
+ 0,
192
+ ) ?? DEFAULT_CONTEXT_KNOBS.pluginIdleTurns,
125
193
  resultCapFraction: fraction(
126
194
  'CONTEXT_RESULT_CAP_FRACTION',
127
195
  DEFAULT_CONTEXT_KNOBS.resultCapFraction,
@@ -199,11 +267,13 @@ export function contextBudgetFor(
199
267
  );
200
268
  const summarizeAtTokens = Math.min(
201
269
  Math.floor(window * knobs.summarizeFraction),
270
+ knobs.summarizeMaxTokens,
202
271
  requestCapTokens,
203
272
  summaryInputTokens,
204
273
  );
205
274
  const pruneAtTokens = Math.min(
206
275
  Math.floor(window * knobs.pruneFraction),
276
+ knobs.pruneMaxTokens,
207
277
  summarizeAtTokens,
208
278
  );
209
279
  return {
@@ -212,6 +282,9 @@ export function contextBudgetFor(
212
282
  origin: resolution.origin,
213
283
  summarizeAtTokens,
214
284
  pruneAtTokens,
285
+ staleResultTurns: knobs.staleResultTurns,
286
+ cacheColdMs: knobs.cacheColdMs,
287
+ pluginIdleTurns: knobs.pluginIdleTurns,
215
288
  resultCapChars: Math.min(
216
289
  Math.floor(window * knobs.resultCapFraction) * CHARS_PER_TOKEN,
217
290
  knobs.resultCapMaxChars,
@@ -226,11 +299,50 @@ export function contextBudgetFor(
226
299
  };
227
300
  }
228
301
 
229
- /** chars/4 over a message's content, whatever its shape. */
302
+ /**
303
+ * What one image block costs the model, whatever its byte size. Providers
304
+ * bill an image by its pixels, not by its base64: every one the runtime
305
+ * sends is scaled to a 1568 px long edge by the provider (or before, by the
306
+ * `IMAGES` binding), which is at most ~3,300 tokens on the costliest
307
+ * provider formula (width × height / 750). Counting the base64 at chars/4
308
+ * instead made one resized phone photo look like ~128k tokens, enough for
309
+ * the context guard to refuse the turn on a model with a smaller window.
310
+ */
311
+ export const IMAGE_BLOCK_TOKENS = 3_300;
312
+
313
+ function isRecord(value: unknown): value is Record<string, unknown> {
314
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
315
+ }
316
+
317
+ /**
318
+ * An image content block in any of the shapes a message carries: the
319
+ * LangChain data block the attachments build (`type: 'image'` with `data`
320
+ * or `url`) or the OpenAI chat shape (`type: 'image_url'`). File blocks are
321
+ * not images: a document's cost does follow its size, and native documents
322
+ * are kept small (`NATIVE_DOCUMENT_MAX_BYTES`).
323
+ */
324
+ function isImageBlock(block: unknown): boolean {
325
+ return (
326
+ isRecord(block) && (block.type === 'image' || block.type === 'image_url')
327
+ );
328
+ }
329
+
330
+ /**
331
+ * chars/4 over a message's content, whatever its shape; each image block
332
+ * counts {@link IMAGE_BLOCK_TOKENS} instead of its base64.
333
+ */
230
334
  export function estimateContentTokens(content: unknown): number {
231
335
  if (typeof content === 'string') return estimateTokensApprox(content);
232
336
  if (content == null) return 0;
233
337
  try {
338
+ if (Array.isArray(content) && content.some(isImageBlock)) {
339
+ const images = content.filter(isImageBlock).length;
340
+ const rest = content.filter((block) => !isImageBlock(block));
341
+ return (
342
+ images * IMAGE_BLOCK_TOKENS +
343
+ (rest.length ? estimateTokensApprox(JSON.stringify(rest)) : 0)
344
+ );
345
+ }
234
346
  return estimateTokensApprox(JSON.stringify(content));
235
347
  } catch {
236
348
  return 0;
@@ -282,5 +394,5 @@ export function messageToolCallTokens(
282
394
  }
283
395
 
284
396
  export function describeBudget(b: ContextBudget): string {
285
- 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}`;
397
+ return `model=${b.model} window=${b.windowTokens} (${b.origin}) summarizeAt=${b.summarizeAtTokens} pruneAt=${b.pruneAtTokens} staleTurns=${b.staleResultTurns} coldMs=${b.cacheColdMs} pluginIdle=${b.pluginIdleTurns} resultCap=${b.resultCapChars}c requestCap=${b.requestCapTokens} reserve=${b.outputReserveTokens} summaryInput=${b.summaryInputTokens}`;
286
398
  }
@@ -7,6 +7,7 @@ import { z } from 'zod';
7
7
 
8
8
  const documentEntry = z.object({
9
9
  id: z.string(),
10
+ name: z.string().optional(),
10
11
  uri: z.string().nullable(),
11
12
  cid: z.string().nullable(),
12
13
  required_for_tasks: z.array(z.string()).optional(),
@@ -51,13 +52,84 @@ export function isPrivateEntry(entry: DomainDocumentEntry): boolean {
51
52
  return entry.access_policy !== 'public' || entry.sensitivity !== 'public';
52
53
  }
53
54
 
55
+ /**
56
+ * How much of a domain the prompt carries on every call.
57
+ *
58
+ * - `compact`: the frontmatter keys `domain.md` §3 lists as "always load",
59
+ * without the id lists and vocabulary URIs behind them, and a one-row-per-
60
+ * document index. Everything else stays one `read_domain_document` away.
61
+ * - `full`: the whole brief, with the permissions of every document.
62
+ */
63
+ export type DomainBriefDetail = 'compact' | 'full';
64
+
65
+ /** One row of the compact index; permissions only where they are not the open default. */
66
+ function compactEntry(entry: DomainDocumentEntry): Record<string, unknown> {
67
+ const { read, cite, summarize } = entry.agent_use;
68
+ return {
69
+ id: entry.id,
70
+ ...(entry.name ? { name: entry.name } : {}),
71
+ role: entry.role,
72
+ pass: entry.disclosure_pass,
73
+ ...(entry.required_for_tasks?.length
74
+ ? { tasks: entry.required_for_tasks }
75
+ : {}),
76
+ ...(isPrivateEntry(entry)
77
+ ? {
78
+ access_policy: entry.access_policy,
79
+ sensitivity: entry.sensitivity,
80
+ }
81
+ : {}),
82
+ ...(read && cite && summarize ? {} : { agent_use: entry.agent_use }),
83
+ };
84
+ }
85
+
86
+ /** The compact brief: what §3 of the specification has an agent always load. */
87
+ function compactBrief(document: DomainDocument): Record<string, unknown> {
88
+ const front = document.frontmatter;
89
+ const domain = record(front.domain);
90
+ const constitution = record(front.constitution);
91
+ const source = record(front.source_of_truth);
92
+ const execution = record(constitution.execution);
93
+ return {
94
+ domain: {
95
+ id: domain.id,
96
+ type: domain.type,
97
+ status: domain.status,
98
+ purpose: domain.purpose,
99
+ operating_boundary: domain.operating_boundary,
100
+ },
101
+ constitution: {
102
+ status: constitution.status,
103
+ legal_effect: record(constitution.legal_effect).status,
104
+ execution_mode: execution.mode,
105
+ failure_policy: execution.failure_policy,
106
+ ai_mode: record(constitution.constitutional_ai).mode,
107
+ },
108
+ source_of_truth: {
109
+ iid_document: source.iid_document,
110
+ conflict_resolution_order: source.conflict_resolution_order,
111
+ },
112
+ agent_default_mode: front.agent_default_mode,
113
+ controllers: record(front.controllers).summary,
114
+ rights: record(front.rights).agent_baseline,
115
+ privacy: { default_policy: record(front.privacy).default_policy },
116
+ critical_do_not: front.critical_do_not,
117
+ documents: entries(document).map(compactEntry),
118
+ };
119
+ }
120
+
54
121
  /**
55
122
  * The parts of the frontmatter the model needs to orient itself: purpose and
56
123
  * boundary, constitution, sources of truth, default agent mode, controllers,
57
124
  * baseline rights, privacy, the critical don'ts and the document index
58
- * (without URIs or CIDs). Serialized once per CID by the resolver.
125
+ * (without URIs or CIDs), at `detail`. Serialized once per CID and detail by
126
+ * the resolver.
59
127
  */
60
- export function briefBody(document: DomainDocument): Record<string, unknown> {
128
+ export function briefBody(
129
+ document: DomainDocument,
130
+ detail: DomainBriefDetail = 'compact',
131
+ ): Record<string, unknown> {
132
+ if (detail === 'compact') return compactBrief(document);
61
133
  const front = document.frontmatter;
62
134
  return {
63
135
  domain: front.domain,
@@ -1,3 +1,4 @@
1
+ export type { DomainBriefDetail } from './document';
1
2
  export type {
2
3
  DocumentRequest,
3
4
  DomainAnchor,
@@ -25,6 +25,7 @@
25
25
  import type { DomainDocument } from '@ixo/domain.md/workers';
26
26
  import { z } from 'zod';
27
27
  import { estimateTokensApprox } from '../manifest';
28
+ import type { DomainBriefDetail } from './document';
28
29
  import { briefBody, record } from './document';
29
30
  import { boundFindings } from './findings';
30
31
  import { decode, INDEX_MAX_BYTES, verifyBytes } from './integrity';
@@ -109,7 +110,7 @@ interface ParsedIndex {
109
110
  findings: string[];
110
111
  unresolvedChecks: string[];
111
112
  document?: DomainDocument;
112
- brief?: { json: string; tokens: number };
113
+ briefs?: Partial<Record<DomainBriefDetail, { json: string; tokens: number }>>;
113
114
  /** Keyed by `<did>\n<manifest cid>`; only deterministic outcomes are kept. */
114
115
  capsules: LruMap<string, CapsuleResult>;
115
116
  }
@@ -320,20 +321,22 @@ export class DomainContextResolver {
320
321
 
321
322
  /**
322
323
  * The serialized brief of a verified snapshot and its token estimate,
323
- * computed once per index CID.
324
+ * computed once per index CID and detail.
324
325
  */
325
326
  brief(
326
327
  snapshot: DomainSnapshot,
328
+ detail: DomainBriefDetail = 'compact',
327
329
  ): { json: string; tokens: number } | undefined {
328
330
  if (!snapshot.document) return undefined;
329
331
  const entry = snapshot.anchor
330
332
  ? this.parsed.get(snapshot.anchor.cid)
331
333
  : undefined;
332
- if (entry?.brief && entry.document === snapshot.document)
333
- return entry.brief;
334
- const json = JSON.stringify(briefBody(snapshot.document));
334
+ const cached = entry?.briefs?.[detail];
335
+ if (cached && entry?.document === snapshot.document) return cached;
336
+ const json = JSON.stringify(briefBody(snapshot.document, detail));
335
337
  const brief = { json, tokens: estimateTokensApprox(json) };
336
- if (entry && entry.document === snapshot.document) entry.brief = brief;
338
+ if (entry && entry.document === snapshot.document)
339
+ entry.briefs = { ...entry.briefs, [detail]: brief };
337
340
  return brief;
338
341
  }
339
342