@ixo/oracle-runtime-workers 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/core/context-budget.ts +201 -0
- package/src/core/context-window.ts +289 -0
- package/src/core/env.ts +29 -0
- package/src/core/main-agent-types.ts +46 -0
- package/src/core/main-agent.ts +86 -9
- package/src/core/middlewares/context-guard.ts +270 -0
- package/src/core/middlewares/result-cap.ts +180 -0
- package/src/core/middlewares/summarization.ts +87 -6
- package/src/core/middlewares/tool-marks.ts +209 -0
- package/src/core/openrouter-pricing.ts +42 -1
- package/src/core/read-result-tool.ts +77 -0
- package/src/core/runtime-context.ts +7 -0
- package/src/core/sub-agent-fallback.ts +20 -3
- package/src/core/wrap-plugin-tool.ts +24 -3
- package/src/do/ambient.ts +17 -0
- package/src/do/contracts.ts +229 -0
- package/src/do/flush-schedule.ts +40 -0
- package/src/do/result-store-test-do.ts +88 -0
- package/src/do/result-store.ts +339 -0
- package/src/do/run-buffer.ts +204 -0
- package/src/do/run-coordinator.ts +572 -0
- package/src/do/run-request.ts +52 -0
- package/src/do/run-store-test-do.ts +130 -0
- package/src/do/run-store.ts +604 -0
- package/src/do/session-id-migration.ts +85 -0
- package/src/do/speaker-prefix.ts +37 -0
- package/src/do/sse-stream.ts +567 -400
- package/src/do/transcript.ts +238 -0
- package/src/do/turn-body.ts +10 -0
- package/src/do/user-oracle-do.ts +948 -162
- package/src/matrix/gateway-do.ts +230 -29
- package/src/matrix/group-chat-summarizer.ts +107 -0
- package/src/matrix/group-chat.ts +1163 -0
- package/src/matrix/inbox-store.ts +23 -3
- package/src/matrix/ingest.ts +54 -11
- package/src/matrix/reply-chain.ts +34 -25
- package/src/plugin-api/tool-helper.ts +9 -1
- package/src/plugin-api/types.ts +86 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/matrix-group-chats/README.md +70 -0
- package/src/plugins/matrix-group-chats/index.ts +132 -0
- package/src/plugins/matrix-group-chats/tools.ts +172 -0
- package/src/plugins/tasks/tasks-tools.ts +4 -0
- package/src/shell/app.ts +66 -0
- package/src/sqlite/sqlite-saver.ts +81 -0
- package/src/tasks/scheduler.ts +134 -3
- 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
|
|
121
|
-
|
|
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.
|
|
3
|
+
"version": "0.11.1",
|
|
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
|