@ixo/oracle-runtime-workers 0.10.0 → 0.11.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 (48) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/context-budget.ts +201 -0
  4. package/src/core/context-window.ts +289 -0
  5. package/src/core/env.ts +29 -0
  6. package/src/core/main-agent-types.ts +46 -0
  7. package/src/core/main-agent.ts +86 -9
  8. package/src/core/middlewares/context-guard.ts +270 -0
  9. package/src/core/middlewares/result-cap.ts +180 -0
  10. package/src/core/middlewares/summarization.ts +87 -6
  11. package/src/core/middlewares/tool-marks.ts +209 -0
  12. package/src/core/openrouter-pricing.ts +42 -1
  13. package/src/core/read-result-tool.ts +77 -0
  14. package/src/core/runtime-context.ts +7 -0
  15. package/src/core/sub-agent-fallback.ts +20 -3
  16. package/src/core/wrap-plugin-tool.ts +24 -3
  17. package/src/do/ambient.ts +17 -0
  18. package/src/do/contracts.ts +227 -0
  19. package/src/do/result-store-test-do.ts +88 -0
  20. package/src/do/result-store.ts +339 -0
  21. package/src/do/run-buffer.ts +204 -0
  22. package/src/do/run-coordinator.ts +572 -0
  23. package/src/do/run-request.ts +52 -0
  24. package/src/do/run-store-test-do.ts +130 -0
  25. package/src/do/run-store.ts +604 -0
  26. package/src/do/session-id-migration.ts +85 -0
  27. package/src/do/speaker-prefix.ts +37 -0
  28. package/src/do/sse-stream.ts +567 -400
  29. package/src/do/transcript.ts +238 -0
  30. package/src/do/turn-body.ts +10 -0
  31. package/src/do/user-oracle-do.ts +875 -146
  32. package/src/matrix/gateway-do.ts +230 -29
  33. package/src/matrix/group-chat-summarizer.ts +107 -0
  34. package/src/matrix/group-chat.ts +1163 -0
  35. package/src/matrix/inbox-store.ts +23 -3
  36. package/src/matrix/ingest.ts +54 -11
  37. package/src/matrix/reply-chain.ts +34 -25
  38. package/src/plugin-api/tool-helper.ts +9 -1
  39. package/src/plugin-api/types.ts +86 -0
  40. package/src/plugins/index.ts +3 -0
  41. package/src/plugins/matrix-group-chats/README.md +70 -0
  42. package/src/plugins/matrix-group-chats/index.ts +132 -0
  43. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  44. package/src/plugins/tasks/tasks-tools.ts +4 -0
  45. package/src/shell/app.ts +66 -0
  46. package/src/sqlite/sqlite-saver.ts +81 -0
  47. package/src/tasks/scheduler.ts +134 -3
  48. package/src/tasks/store.ts +22 -0
package/README.md CHANGED
@@ -117,5 +117,8 @@ lifetime are in [operations](docs/operations.md).
117
117
  - Open: the memory engine's recall ranking on accounts with a long history;
118
118
  the storage cost of task-holding users at scale (R2 page tier, see
119
119
  [architecture](docs/architecture.md#storage-cost-planning)).
120
- - Not ported on purpose: the Slack transport, the commerce lane and Matrix
121
- group chats — see [node-parity](docs/node-parity.md#not-ported).
120
+ - Not ported on purpose: the Slack transport and the commerce lane — see
121
+ [node-parity](docs/node-parity.md#not-ported). Matrix group chats were
122
+ ported in September 2026 behind `MATRIX_GROUP_ROOMS=gate` (the gate and
123
+ channel memory run in the gateway; by default the bot stays silent in
124
+ group rooms, see [operations](docs/operations.md#rooms-group-chats)).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Every context-related limit of a turn, derived from the model's window
3
+ * (`context-window.ts`) so that they scale with the model:
4
+ *
5
+ * - `summarizeAtTokens` — the history is condensed once it is this large
6
+ * (default half the window; Hermes fires at 50%);
7
+ * - `pruneAtTokens` — above this, old tool results are demoted to
8
+ * one-liners in the request (no model call, no
9
+ * state change) before the model sees them;
10
+ * - `resultCapChars` — a tool result larger than this is stored whole
11
+ * and the model sees head + tail + a handle;
12
+ * - `requestCapTokens` — a request that is still larger than this after
13
+ * pruning is refused instead of sent to fail;
14
+ * - `outputReserveTokens`— the room left for the reply.
15
+ *
16
+ * Token counts are chars/4 estimates (`estimateTokensApprox`), never billing.
17
+ */
18
+ import type { Logger } from '../plugin-api/types';
19
+ import type { ContextWindowResolution } from './context-window';
20
+ import { estimateTokensApprox } from './manifest';
21
+ import { NOOP_LOGGER } from './utils';
22
+
23
+ export const CHARS_PER_TOKEN = 4;
24
+
25
+ export interface ContextKnobs {
26
+ /** Fraction of the window at which the history is summarized (default 0.5). */
27
+ summarizeFraction: number;
28
+ /** Fraction of the window above which old tool results are pruned per request (default 0.35). */
29
+ pruneFraction: number;
30
+ /** Fraction of the window one tool result may occupy before it is capped (default 0.12). */
31
+ resultCapFraction: number;
32
+ /**
33
+ * Ceiling on the result cap in chars (default 200,000 ≈ 50k tokens): a
34
+ * million-token window does not make a half-megabyte tool result a good
35
+ * idea — the rest stays reachable through `read_result`.
36
+ */
37
+ resultCapMaxChars: number;
38
+ /** Fraction of the window a request may use, output reserve included (default 0.95). */
39
+ requestFraction: number;
40
+ /** Tokens reserved for the reply (default 8000). */
41
+ outputReserveTokens: number;
42
+ /** Optional message-count trigger for summarization (unset = tokens only). */
43
+ summarizeTriggerMessages?: number;
44
+ /** Recent messages kept verbatim by the summarizer (default 10). */
45
+ keepMessages: number;
46
+ }
47
+
48
+ export const DEFAULT_CONTEXT_KNOBS: ContextKnobs = {
49
+ summarizeFraction: 0.5,
50
+ pruneFraction: 0.35,
51
+ resultCapFraction: 0.12,
52
+ resultCapMaxChars: 200_000,
53
+ requestFraction: 0.95,
54
+ outputReserveTokens: 8_000,
55
+ keepMessages: 10,
56
+ };
57
+
58
+ export interface ContextBudget {
59
+ model: string;
60
+ windowTokens: number;
61
+ origin: ContextWindowResolution['origin'];
62
+ summarizeAtTokens: number;
63
+ pruneAtTokens: number;
64
+ resultCapChars: number;
65
+ requestCapTokens: number;
66
+ outputReserveTokens: number;
67
+ /** The summarizer's own input limit (what it may read to write the summary). */
68
+ summaryInputTokens: number;
69
+ summarizeTriggerMessages?: number;
70
+ keepMessages: number;
71
+ }
72
+
73
+ /** Parse the `CONTEXT_*` env knobs; anything unusable keeps its default (with a warning). */
74
+ export function contextKnobs(
75
+ env: Record<string, unknown>,
76
+ logger: Logger = NOOP_LOGGER,
77
+ ): ContextKnobs {
78
+ const fraction = (key: string, fallback: number): number => {
79
+ const raw = env[key];
80
+ if (typeof raw !== 'string' || raw.trim() === '') return fallback;
81
+ const n = Number(raw);
82
+ if (Number.isFinite(n) && n > 0 && n <= 1) return n;
83
+ logger.warn(
84
+ `[context] ${key}=${raw} is not a fraction in (0, 1]; using ${fallback}`,
85
+ );
86
+ return fallback;
87
+ };
88
+ const int = (
89
+ key: string,
90
+ fallback: number | undefined,
91
+ min: number,
92
+ ): number | undefined => {
93
+ const raw = env[key];
94
+ if (typeof raw !== 'string' || raw.trim() === '') return fallback;
95
+ const n = Number(raw);
96
+ if (Number.isInteger(n) && n >= min) return n;
97
+ logger.warn(
98
+ `[context] ${key}=${raw} is not an integer ≥ ${min}; using ${fallback ?? 'unset'}`,
99
+ );
100
+ return fallback;
101
+ };
102
+ const summarizeTriggerMessages = int(
103
+ 'CONTEXT_SUMMARIZE_MESSAGES',
104
+ undefined,
105
+ 4,
106
+ );
107
+ return {
108
+ summarizeFraction: fraction(
109
+ 'CONTEXT_SUMMARIZE_FRACTION',
110
+ DEFAULT_CONTEXT_KNOBS.summarizeFraction,
111
+ ),
112
+ pruneFraction: fraction(
113
+ 'CONTEXT_PRUNE_FRACTION',
114
+ DEFAULT_CONTEXT_KNOBS.pruneFraction,
115
+ ),
116
+ resultCapFraction: fraction(
117
+ 'CONTEXT_RESULT_CAP_FRACTION',
118
+ DEFAULT_CONTEXT_KNOBS.resultCapFraction,
119
+ ),
120
+ resultCapMaxChars:
121
+ int(
122
+ 'CONTEXT_RESULT_CAP_MAX_CHARS',
123
+ DEFAULT_CONTEXT_KNOBS.resultCapMaxChars,
124
+ 4_000,
125
+ ) ?? DEFAULT_CONTEXT_KNOBS.resultCapMaxChars,
126
+ requestFraction: fraction(
127
+ 'CONTEXT_REQUEST_FRACTION',
128
+ DEFAULT_CONTEXT_KNOBS.requestFraction,
129
+ ),
130
+ outputReserveTokens:
131
+ int(
132
+ 'CONTEXT_OUTPUT_RESERVE_TOKENS',
133
+ DEFAULT_CONTEXT_KNOBS.outputReserveTokens,
134
+ 256,
135
+ ) ?? DEFAULT_CONTEXT_KNOBS.outputReserveTokens,
136
+ ...(summarizeTriggerMessages !== undefined
137
+ ? { summarizeTriggerMessages }
138
+ : {}),
139
+ keepMessages:
140
+ int('CONTEXT_KEEP_MESSAGES', DEFAULT_CONTEXT_KNOBS.keepMessages, 2) ??
141
+ DEFAULT_CONTEXT_KNOBS.keepMessages,
142
+ };
143
+ }
144
+
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;
151
+ // The reply reserve can never eat a small window: cap it at a quarter.
152
+ const outputReserveTokens = Math.min(
153
+ knobs.outputReserveTokens,
154
+ Math.floor(window / 4),
155
+ );
156
+ const requestCapTokens = Math.max(
157
+ 1_000,
158
+ Math.floor(window * knobs.requestFraction) - outputReserveTokens,
159
+ );
160
+ const summarizeAtTokens = Math.min(
161
+ Math.floor(window * knobs.summarizeFraction),
162
+ requestCapTokens,
163
+ );
164
+ const pruneAtTokens = Math.min(
165
+ Math.floor(window * knobs.pruneFraction),
166
+ summarizeAtTokens,
167
+ );
168
+ return {
169
+ model: resolution.model,
170
+ windowTokens: window,
171
+ origin: resolution.origin,
172
+ summarizeAtTokens,
173
+ pruneAtTokens,
174
+ resultCapChars: Math.min(
175
+ Math.floor(window * knobs.resultCapFraction) * CHARS_PER_TOKEN,
176
+ knobs.resultCapMaxChars,
177
+ ),
178
+ requestCapTokens,
179
+ outputReserveTokens,
180
+ summaryInputTokens: Math.max(1_000, requestCapTokens - 4_000),
181
+ ...(knobs.summarizeTriggerMessages !== undefined
182
+ ? { summarizeTriggerMessages: knobs.summarizeTriggerMessages }
183
+ : {}),
184
+ keepMessages: knobs.keepMessages,
185
+ };
186
+ }
187
+
188
+ /** chars/4 over a message's content, whatever its shape. */
189
+ export function estimateContentTokens(content: unknown): number {
190
+ if (typeof content === 'string') return estimateTokensApprox(content);
191
+ if (content == null) return 0;
192
+ try {
193
+ return estimateTokensApprox(JSON.stringify(content));
194
+ } catch {
195
+ return 0;
196
+ }
197
+ }
198
+
199
+ 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}`;
201
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The context window of the model a turn runs on — resolved per model, not
3
+ * configured once for the deployment.
4
+ *
5
+ * Every context budget (when to summarize, how large a tool result may be
6
+ * before it is capped, when a request no longer fits) is a fraction of this
7
+ * number, so a 1M-context model is treated differently from a 32k one.
8
+ *
9
+ * Resolution order, first hit wins:
10
+ *
11
+ * 1. an operator override for the exact model id (`MODEL_CONTEXT_OVERRIDES`);
12
+ * 2. the OpenRouter `/models` listing (the same cached fetch that serves
13
+ * the price catalog) — for OpenRouter ids directly, and for BYO
14
+ * provider-native ids through the vendor prefix (`gpt-…` → `openai/gpt-…`);
15
+ * 3. the deployment default (`MODEL_CONTEXT_TOKENS`, 100k unless set).
16
+ *
17
+ * On top of that, a window is **learned downwards**: when a provider rejects
18
+ * a request as too long and names its limit, that smaller number is kept for
19
+ * the model (persistently, when the host supplies a store) and wins over the
20
+ * catalog from then on. A provider's error is the one source that is never
21
+ * wrong about its own limit; nothing is ever learned upwards.
22
+ *
23
+ * Modelled on Hermes Agent's `get_model_context_length` chain, trimmed to the
24
+ * sources this runtime has.
25
+ */
26
+ import type { Logger } from '../plugin-api/types';
27
+ import { NOOP_LOGGER } from './utils';
28
+
29
+ export const DEFAULT_CONTEXT_TOKENS = 100_000;
30
+ export const MIN_CONTEXT_TOKENS = 16_000;
31
+ /** Above this a "window" is not a window (a parse slip); ignored. */
32
+ export const MAX_CONTEXT_TOKENS = 20_000_000;
33
+
34
+ export type ContextWindowOrigin =
35
+ | 'override'
36
+ | 'learned'
37
+ | 'catalog'
38
+ | 'default';
39
+
40
+ export interface ContextWindowResolution {
41
+ /** The id the lookup was made for (as given). */
42
+ model: string;
43
+ tokens: number;
44
+ origin: ContextWindowOrigin;
45
+ /** The catalog id that answered, when the catalog did. */
46
+ catalogId?: string;
47
+ }
48
+
49
+ export interface ContextWindowConfig {
50
+ defaultTokens: number;
51
+ overrides: ReadonlyMap<string, number>;
52
+ }
53
+
54
+ /** Persisted learned limits (the host backs this with Durable Object storage). */
55
+ export interface LearnedWindowStore {
56
+ get(model: string): Promise<number | undefined>;
57
+ set(model: string, tokens: number): Promise<void>;
58
+ }
59
+
60
+ export interface ContextWindowResolverOptions {
61
+ config: ContextWindowConfig;
62
+ /** OpenRouter model id → context window (tokens). */
63
+ catalog: () => Promise<ReadonlyMap<string, number>>;
64
+ learned?: LearnedWindowStore;
65
+ logger?: Logger;
66
+ }
67
+
68
+ /**
69
+ * `MODEL_CONTEXT_OVERRIDES="openai/gpt-5.6-luna=400000, gpt-5.6-terra=1000000"`
70
+ * — comma-separated `<model id>=<tokens>`; malformed entries are skipped
71
+ * with a warning. `MODEL_CONTEXT_TOKENS` is the default for models nothing
72
+ * else knows (min 16k).
73
+ */
74
+ export function contextWindowConfig(
75
+ env: Record<string, unknown>,
76
+ logger: Logger = NOOP_LOGGER,
77
+ ): ContextWindowConfig {
78
+ const rawDefault = env.MODEL_CONTEXT_TOKENS;
79
+ const parsedDefault =
80
+ typeof rawDefault === 'string' && rawDefault.trim() !== ''
81
+ ? Number(rawDefault)
82
+ : Number.NaN;
83
+ const defaultTokens = isWindow(parsedDefault)
84
+ ? Math.floor(parsedDefault)
85
+ : DEFAULT_CONTEXT_TOKENS;
86
+ if (
87
+ typeof rawDefault === 'string' &&
88
+ rawDefault.trim() !== '' &&
89
+ !isWindow(parsedDefault)
90
+ )
91
+ logger.warn(
92
+ `[context] MODEL_CONTEXT_TOKENS=${rawDefault} is not a usable window; using ${DEFAULT_CONTEXT_TOKENS}`,
93
+ );
94
+ const overrides = new Map<string, number>();
95
+ const rawOverrides = env.MODEL_CONTEXT_OVERRIDES;
96
+ if (typeof rawOverrides === 'string' && rawOverrides.trim() !== '') {
97
+ for (const entry of rawOverrides.split(',')) {
98
+ const trimmed = entry.trim();
99
+ if (!trimmed) continue;
100
+ const eq = trimmed.lastIndexOf('=');
101
+ const model = eq > 0 ? trimmed.slice(0, eq).trim() : '';
102
+ const tokens = eq > 0 ? Number(trimmed.slice(eq + 1).trim()) : Number.NaN;
103
+ if (!model || !isWindow(tokens)) {
104
+ logger.warn(
105
+ `[context] MODEL_CONTEXT_OVERRIDES entry ignored: "${trimmed}"`,
106
+ );
107
+ continue;
108
+ }
109
+ overrides.set(model, Math.floor(tokens));
110
+ }
111
+ }
112
+ return { defaultTokens, overrides };
113
+ }
114
+
115
+ function isWindow(n: number): boolean {
116
+ return (
117
+ Number.isFinite(n) && n >= MIN_CONTEXT_TOKENS && n <= MAX_CONTEXT_TOKENS
118
+ );
119
+ }
120
+
121
+ /**
122
+ * OpenRouter routing variants (`:nitro`, `:floor`, `:online`, `:free`, …) are
123
+ * request-time modifiers, not catalog entries: look the base id up.
124
+ */
125
+ export function stripRoutingVariant(model: string): string {
126
+ const slash = model.indexOf('/');
127
+ const colon = model.lastIndexOf(':');
128
+ return colon > slash ? model.slice(0, colon) : model;
129
+ }
130
+
131
+ /**
132
+ * Candidate catalog ids for a model id, in order. A provider-native BYO id
133
+ * (`gpt-5.6-luna`, `claude-sonnet-5`, `gemini-3.6-flash`, `deepseek-v4-pro`)
134
+ * is looked up under the vendor prefix OpenRouter lists it with.
135
+ */
136
+ export function catalogCandidates(
137
+ model: string,
138
+ byoProvider?: string,
139
+ ): string[] {
140
+ const base = stripRoutingVariant(model.replace(/^byo:/, ''));
141
+ if (base.includes('/')) return [base];
142
+ const vendors: Record<string, string> = {
143
+ chatgpt: 'openai',
144
+ openai: 'openai',
145
+ anthropic: 'anthropic',
146
+ gemini: 'google',
147
+ deepseek: 'deepseek',
148
+ };
149
+ const byName =
150
+ base.startsWith('gpt-') || /^o\d/.test(base)
151
+ ? 'openai'
152
+ : base.startsWith('claude-')
153
+ ? 'anthropic'
154
+ : base.startsWith('gemini-')
155
+ ? 'google'
156
+ : base.startsWith('deepseek-')
157
+ ? 'deepseek'
158
+ : undefined;
159
+ const prefixes = new Set<string>();
160
+ const byProvider = byoProvider ? vendors[byoProvider] : undefined;
161
+ if (byProvider) prefixes.add(byProvider);
162
+ if (byName) prefixes.add(byName);
163
+ return [...prefixes].map((p) => `${p}/${base}`);
164
+ }
165
+
166
+ const LIMIT_PATTERNS: RegExp[] = [
167
+ // OpenAI / OpenRouter / most OpenAI-compatible gateways
168
+ /maximum context length is (\d[\d,]*) tokens/i,
169
+ /context length of only (\d[\d,]*) tokens/i,
170
+ /context window of (\d[\d,]*) tokens/i,
171
+ // Anthropic: "prompt is too long: 213000 tokens > 200000 maximum"
172
+ /tokens\s*>\s*(\d[\d,]*)\s*maximum/i,
173
+ // Gemini: "input token count (1200000) exceeds the maximum number of tokens allowed (1048576)"
174
+ /maximum number of tokens allowed \((\d[\d,]*)\)/i,
175
+ // Generic "limit: 128000 tokens" / "max_tokens: 128000"
176
+ /(?:token limit|limit)(?: of| is|:)?\s*(\d[\d,]*)\s*tokens/i,
177
+ ];
178
+
179
+ /** The limit a provider's "too long" error names, in tokens, or `undefined`. */
180
+ export function parseContextLimit(message: string): number | undefined {
181
+ for (const pattern of LIMIT_PATTERNS) {
182
+ const match = pattern.exec(message);
183
+ if (!match?.[1]) continue;
184
+ const n = Number(match[1].replace(/,/g, ''));
185
+ if (isWindow(n)) return n;
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ /** A provider rejected the request because the prompt no longer fits. */
191
+ export function isContextOverflowError(error: unknown): boolean {
192
+ const message = error instanceof Error ? error.message : String(error ?? '');
193
+ return /context_length_exceeded|maximum context length|context window|prompt is too long|input tokens exceed|too many tokens|exceeds the maximum number of tokens|request too large for|reduce the length of the messages/i.test(
194
+ message,
195
+ );
196
+ }
197
+
198
+ export class ContextWindowResolver {
199
+ private readonly logger: Logger;
200
+
201
+ /** Learned limits seen this boot (so a persisted read happens once per model). */
202
+ private readonly learnedCache = new Map<string, number | undefined>();
203
+
204
+ constructor(private readonly options: ContextWindowResolverOptions) {
205
+ this.logger = options.logger ?? NOOP_LOGGER;
206
+ }
207
+
208
+ async resolve(
209
+ model: string,
210
+ opts: { byoProvider?: string } = {},
211
+ ): Promise<ContextWindowResolution> {
212
+ const base = await this.baseline(model, opts.byoProvider);
213
+ const learned = await this.learned(model);
214
+ if (learned !== undefined && learned < base.tokens) {
215
+ return { ...base, tokens: learned, origin: 'learned' };
216
+ }
217
+ return base;
218
+ }
219
+
220
+ private async baseline(
221
+ model: string,
222
+ byoProvider?: string,
223
+ ): Promise<ContextWindowResolution> {
224
+ const { config } = this.options;
225
+ const stripped = stripRoutingVariant(model.replace(/^byo:/, ''));
226
+ const override =
227
+ config.overrides.get(model) ?? config.overrides.get(stripped);
228
+ if (override !== undefined)
229
+ return { model, tokens: override, origin: 'override' };
230
+ let catalog: ReadonlyMap<string, number> | undefined;
231
+ try {
232
+ catalog = await this.options.catalog();
233
+ } catch (error) {
234
+ this.logger.warn(
235
+ `[context] model catalog unavailable: ${error instanceof Error ? error.message : String(error)}`,
236
+ );
237
+ }
238
+ if (catalog) {
239
+ for (const candidate of catalogCandidates(model, byoProvider)) {
240
+ const tokens = catalog.get(candidate);
241
+ if (tokens !== undefined && isWindow(tokens))
242
+ return { model, tokens, origin: 'catalog', catalogId: candidate };
243
+ }
244
+ }
245
+ return { model, tokens: config.defaultTokens, origin: 'default' };
246
+ }
247
+
248
+ private async learned(model: string): Promise<number | undefined> {
249
+ if (this.learnedCache.has(model)) return this.learnedCache.get(model);
250
+ let value: number | undefined;
251
+ try {
252
+ value = await this.options.learned?.get(model);
253
+ } catch {
254
+ value = undefined;
255
+ }
256
+ this.learnedCache.set(model, value);
257
+ return value;
258
+ }
259
+
260
+ /**
261
+ * A provider said the prompt does not fit: keep the limit it named when it
262
+ * is smaller than what we believed. Returns the new window, or `undefined`
263
+ * when the message names nothing usable (the caller then shrinks blind).
264
+ */
265
+ async learnFromError(
266
+ model: string,
267
+ error: unknown,
268
+ opts: { byoProvider?: string } = {},
269
+ ): Promise<number | undefined> {
270
+ const message =
271
+ error instanceof Error ? error.message : String(error ?? '');
272
+ const limit = parseContextLimit(message);
273
+ if (limit === undefined) return undefined;
274
+ const current = await this.resolve(model, opts);
275
+ if (limit >= current.tokens) return undefined;
276
+ this.learnedCache.set(model, limit);
277
+ try {
278
+ await this.options.learned?.set(model, limit);
279
+ } catch (storeError) {
280
+ this.logger.warn(
281
+ `[context] could not persist the learned window for ${model}: ${storeError instanceof Error ? storeError.message : String(storeError)}`,
282
+ );
283
+ }
284
+ this.logger.warn(
285
+ `[context] ${model}: window lowered ${current.tokens} → ${limit} (from the provider's error)`,
286
+ );
287
+ return limit;
288
+ }
289
+ }
package/src/core/env.ts CHANGED
@@ -115,6 +115,35 @@ export const baseEnvSchema = z.object({
115
115
  .min(1)
116
116
  .default(TURN_RECURSION_LIMIT_DEFAULT),
117
117
 
118
+ // --- durable runs (docs/plans/durable-runs.md) ---------------------------
119
+ // Parsed by `runDurabilityConfig` (src/do/run-store.ts) with defaults:
120
+ // keep-alive 20 s, segment pack every 2 s / 16 KiB, 4 recovery attempts at
121
+ // 5/15/30/60 s, `interrupt` as the multitask rule.
122
+ RUN_KEEPALIVE_MS: z.string().optional(),
123
+ RUN_SEGMENT_FLUSH_MS: z.string().optional(),
124
+ RUN_SEGMENT_BYTES: z.string().optional(),
125
+ RUN_RECOVERY_ATTEMPTS: z.string().optional(),
126
+ RUN_RECOVERY_DELAYS_MS: z.string().optional(),
127
+ TURN_MULTITASK_DEFAULT: z.enum(['interrupt', 'enqueue']).optional(),
128
+
129
+ // --- context budgets (docs/plans/context-budgets.md) ---------------------
130
+ // Parsed by `contextWindowConfig` / `contextKnobs` / `resultStoreKnobs`.
131
+ // The model's context window is resolved per model (override → OpenRouter
132
+ // catalog → MODEL_CONTEXT_TOKENS default, lowered by provider errors); the
133
+ // fractions derive every limit from it.
134
+ MODEL_CONTEXT_TOKENS: z.string().optional(),
135
+ MODEL_CONTEXT_OVERRIDES: z.string().optional(),
136
+ CONTEXT_SUMMARIZE_FRACTION: z.string().optional(),
137
+ CONTEXT_PRUNE_FRACTION: z.string().optional(),
138
+ CONTEXT_RESULT_CAP_FRACTION: z.string().optional(),
139
+ CONTEXT_RESULT_CAP_MAX_CHARS: z.string().optional(),
140
+ CONTEXT_REQUEST_FRACTION: z.string().optional(),
141
+ CONTEXT_OUTPUT_RESERVE_TOKENS: z.string().optional(),
142
+ CONTEXT_SUMMARIZE_MESSAGES: z.string().optional(),
143
+ CONTEXT_KEEP_MESSAGES: z.string().optional(),
144
+ TOOL_RESULT_TTL_HOURS: z.string().optional(),
145
+ TOOL_RESULT_R2_MIN_BYTES: z.string().optional(),
146
+
118
147
  // --- misc ---------------------------------------------------------------
119
148
  LOG_LEVEL: z.string().default('info'),
120
149
  CORS_ORIGIN: z.string().default('*'),
@@ -1,3 +1,7 @@
1
+ import type { ContextBudget } from './context-budget';
2
+ import type { ContextGuardEvent } from './middlewares/context-guard';
3
+ import type { ResultCapConfig } from './middlewares/result-cap';
4
+ import type { ReadResultOutcome } from '../do/result-store';
1
5
  import type { AgentMiddleware } from 'langchain';
2
6
  import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
3
7
  import type { BaseCheckpointSaver } from '@langchain/langgraph';
@@ -75,6 +79,37 @@ export interface MainAgentHooks {
75
79
  * producer the core cannot construct).
76
80
  */
77
81
  middlewares?: AgentMiddleware[];
82
+ /**
83
+ * Middlewares applied to the main agent AND to every sub-agent's inner
84
+ * graph (a sub-agent's own tool calls go through them too): the
85
+ * write-ahead tool marks of durable runs and the result cap live here.
86
+ */
87
+ toolMiddlewares?: AgentMiddleware[];
88
+ /**
89
+ * The turn's result cap (result-cap.ts), applied inside every wrapped
90
+ * tool — plugin, meta and sub-agent inner tools — so a large result is
91
+ * truncated and saved before it becomes a ToolMessage or an SSE frame.
92
+ */
93
+ resultCap?: ResultCapConfig;
94
+ /**
95
+ * The provider rejected a request as too long (context-guard.ts): learn
96
+ * the limit it named. Resolves to the new window when there is one.
97
+ */
98
+ onContextOverflow?: (error: unknown) => Promise<number | undefined>;
99
+ /**
100
+ * What the context guard did to a request (a prune, an overflow retry, a
101
+ * refusal): the host keeps per-session counters for diagnostics.
102
+ */
103
+ onContextEvent?: (event: ContextGuardEvent) => void;
104
+ /**
105
+ * Pages through a tool result the result cap saved whole; when present
106
+ * the model gets the `read_result` tool.
107
+ */
108
+ readResult?: (
109
+ id: string,
110
+ offset: number,
111
+ length: number,
112
+ ) => Promise<ReadResultOutcome>;
78
113
  }
79
114
 
80
115
  export interface MainAgentArgs {
@@ -100,6 +135,12 @@ export interface MainAgentArgs {
100
135
  abortSignal?: AbortSignal;
101
136
  /** Provider of a BYO turn (`runtime.context.byo`); absent on platform turns. */
102
137
  byoProvider?: string;
138
+ /**
139
+ * The turn's context budget (context-budget.ts): drives when the history
140
+ * is summarized, how large a request may be, and the `read_result` chunk
141
+ * size. Omitted → the legacy fixed thresholds (tests, stateless builds).
142
+ */
143
+ contextBudget?: ContextBudget;
103
144
  hooks?: MainAgentHooks;
104
145
  }
105
146
 
@@ -114,6 +155,11 @@ export interface MainAgentBuildResult {
114
155
  systemPrompt: string;
115
156
  /** Names of every tool bound to the agent, in binding order. */
116
157
  boundToolNames: string[];
158
+ /**
159
+ * The effect of every bound tool (`read` may run again on a resumed
160
+ * turn, `write` never does). Declared by the plugin, else by name.
161
+ */
162
+ toolEffects: Map<string, 'read' | 'write'>;
117
163
  /**
118
164
  * The request's `{ user, session }` channel. Tools already fall back to it,
119
165
  * but pass it as `context` in the `invoke` / `stream` config so LangChain