@ixo/oracle-runtime-workers 0.16.0 → 0.17.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.
- package/README.md +3 -0
- package/package.json +5 -3
- package/src/core/budgeted-llm.ts +115 -6
- package/src/core/context-budget.ts +24 -26
- package/src/core/context-window.ts +39 -12
- package/src/core/domain-context/document.ts +100 -0
- package/src/core/domain-context/findings.ts +25 -0
- package/src/core/domain-context/index.ts +28 -0
- package/src/core/domain-context/integrity.ts +60 -0
- package/src/core/domain-context/lru.ts +63 -0
- package/src/core/domain-context/resolver.ts +651 -0
- package/src/core/domain-context/transport.ts +340 -0
- package/src/core/domain-context/turn-context.ts +443 -0
- package/src/core/domain-context/types.ts +119 -0
- package/src/core/domain-context/validator.ts +41 -0
- package/src/core/env.ts +60 -6
- package/src/core/index.ts +2 -0
- package/src/core/llm.ts +225 -4
- package/src/core/main-agent-types.ts +28 -0
- package/src/core/main-agent.ts +61 -4
- package/src/core/middlewares/tool-execution.ts +19 -2
- package/src/core/registries.ts +14 -3
- package/src/core/state.ts +2 -1
- package/src/core/sub-agent-fallback.ts +14 -2
- package/src/core/turn-budget.ts +19 -22
- package/src/do/contracts.ts +23 -2
- package/src/do/domain-context-store.ts +123 -0
- package/src/do/run-request.ts +6 -0
- package/src/do/sse-stream.ts +1 -1
- package/src/do/transcript.ts +9 -0
- package/src/do/turn-body.ts +2 -1
- package/src/do/turn-metadata.ts +13 -6
- package/src/do/user-oracle-do.ts +109 -42
- package/src/index.ts +16 -0
- package/src/llm/byo-adapter.ts +6 -0
- package/src/llm/byo-catalog.ts +7 -4
- package/src/llm/byo-client.ts +42 -7
- package/src/llm/provider-error.ts +21 -3
- package/src/llm/stream-liveness.ts +498 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +6 -0
package/README.md
CHANGED
|
@@ -136,6 +136,9 @@ lifetime are in [operations](docs/operations.md).
|
|
|
136
136
|
deliverable API, admin-plane tools, anonymous feedback, the POD Creator
|
|
137
137
|
and ixo-transaction plugins (see the pages above and
|
|
138
138
|
[node-parity](docs/node-parity.md#workers-only)).
|
|
139
|
+
- Observe-only domain context (off by default): the oracle's and the
|
|
140
|
+
selected subject's anchored `domain.md` in the prompt, with provenance
|
|
141
|
+
([configuration](docs/configuration.md#domain-context-observe-only-rollout)).
|
|
139
142
|
- Open: the memory engine's recall ranking on accounts with a long history;
|
|
140
143
|
the storage cost of task-holding users at scale (R2 page tier, see
|
|
141
144
|
[architecture](docs/architecture.md#storage-cost-planning)).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"@blocknote/server-util": "0.29.1",
|
|
35
35
|
"@composio/core": "0.10.0",
|
|
36
36
|
"@composio/langchain": "0.9.2",
|
|
37
|
+
"@ixo/domain.md": "^0.3.1",
|
|
37
38
|
"@ixo/editor": "6.0.1",
|
|
38
39
|
"@ixo/matrix-bot-workers-sdk": "0.7.0",
|
|
39
40
|
"@ixo/matrix-crdt": "^1.2.5",
|
|
@@ -52,13 +53,14 @@
|
|
|
52
53
|
"linkedom": "^0.18.12",
|
|
53
54
|
"marked": "^16.4.0",
|
|
54
55
|
"matrix-js-sdk": "^42.2.0",
|
|
56
|
+
"multiformats": "14.0.4",
|
|
55
57
|
"superjson": "1.13.3",
|
|
56
58
|
"wa-sqlite": "1.0.0",
|
|
57
59
|
"yjs": "13.6.32",
|
|
58
60
|
"zod": "^4.4.3",
|
|
61
|
+
"@ixo/ixo-transaction": "0.1.0",
|
|
59
62
|
"@ixo/common": "1.6.0",
|
|
60
|
-
"@ixo/ucan": "2.2.1"
|
|
61
|
-
"@ixo/ixo-transaction": "0.1.0"
|
|
63
|
+
"@ixo/ucan": "2.2.1"
|
|
62
64
|
},
|
|
63
65
|
"devDependencies": {
|
|
64
66
|
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
package/src/core/budgeted-llm.ts
CHANGED
|
@@ -2,18 +2,26 @@
|
|
|
2
2
|
* The turn's LLM adapter, metered: every model it hands out carries a
|
|
3
3
|
* callback that reserves the call against the `TurnBudget` before the
|
|
4
4
|
* provider is contacted and settles the reservation to the provider's
|
|
5
|
-
* reported usage when the call ends.
|
|
5
|
+
* reported usage when the call ends. The reservation refuses a call only
|
|
6
|
+
* once the turn is aborted or past its deadline; the tokens are accounted
|
|
7
|
+
* for the turn's usage report, never limited. Helper models (summarizer, attachment
|
|
6
8
|
* extraction, a plugin's own `ctx.llm.get`) are covered the same way as the
|
|
7
9
|
* main model, since they all come from this adapter.
|
|
8
10
|
*
|
|
9
11
|
* The same handler is also put in the graph's run config (`callbacks`) so a
|
|
10
12
|
* model the host constructed elsewhere and passed in directly is still
|
|
11
13
|
* charged; a call reported through both paths is counted once.
|
|
14
|
+
*
|
|
15
|
+
* The handler also traces each call for operators: `[llm] start` and
|
|
16
|
+
* `[llm] end … in N ms` at debug level, and `[llm] … still running after
|
|
17
|
+
* N s` every minute while a call is open, so a stalled call can be told
|
|
18
|
+
* from a long one. Only the role, model id and tags are logged — never
|
|
19
|
+
* message content or credentials.
|
|
12
20
|
*/
|
|
13
21
|
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
|
|
14
22
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
|
15
23
|
import type { LLMResult } from '@langchain/core/outputs';
|
|
16
|
-
import type { ChatOpenAIFields, ModelRole } from '../plugin-api/types';
|
|
24
|
+
import type { ChatOpenAIFields, Logger, ModelRole } from '../plugin-api/types';
|
|
17
25
|
import {
|
|
18
26
|
estimateContentTokens,
|
|
19
27
|
messageContentTokens,
|
|
@@ -21,6 +29,13 @@ import {
|
|
|
21
29
|
} from './context-budget';
|
|
22
30
|
import type { LlmAdapter } from './runtime-context';
|
|
23
31
|
import type { TurnBudget } from './turn-budget';
|
|
32
|
+
import { NOOP_LOGGER } from './utils';
|
|
33
|
+
|
|
34
|
+
/** How often an open model call is reported as still running. */
|
|
35
|
+
export const LLM_HEARTBEAT_MS = 60_000;
|
|
36
|
+
|
|
37
|
+
/** Metadata key `budgetedLlm` stamps on the models it hands out. */
|
|
38
|
+
const ROLE_METADATA_KEY = 'oracle_model_role';
|
|
24
39
|
|
|
25
40
|
export interface BudgetedLlmOptions {
|
|
26
41
|
budget: TurnBudget;
|
|
@@ -28,11 +43,19 @@ export interface BudgetedLlmOptions {
|
|
|
28
43
|
outputReserveTokens: number;
|
|
29
44
|
/** The turn's abort signal: a call after the abort is refused before it starts. */
|
|
30
45
|
signal?: AbortSignal;
|
|
46
|
+
/** Receives the per-call trace (`[llm] start` / `end` / still running). */
|
|
47
|
+
logger?: Logger;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
export interface BudgetedLlm extends LlmAdapter {
|
|
34
51
|
/** Put this in the run config's `callbacks` as well. */
|
|
35
52
|
readonly callback: BaseCallbackHandler;
|
|
53
|
+
/**
|
|
54
|
+
* End of the turn: clears every heartbeat still open and the abort
|
|
55
|
+
* listener. LangChain reports no end for a stream its consumer stopped
|
|
56
|
+
* reading early, and a live interval keeps the object resident.
|
|
57
|
+
*/
|
|
58
|
+
dispose(): void;
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
/**
|
|
@@ -128,7 +151,10 @@ function reportedTotalTokens(output: LLMResult): number | undefined {
|
|
|
128
151
|
|
|
129
152
|
class TurnBudgetHandler extends BaseCallbackHandler {
|
|
130
153
|
name = 'TurnBudgetHandler';
|
|
131
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* A refused reservation (aborted turn, passed deadline) must fail the
|
|
156
|
+
* model call, not be logged and ignored.
|
|
157
|
+
*/
|
|
132
158
|
raiseError = true;
|
|
133
159
|
awaitHandlers = true;
|
|
134
160
|
private readonly reservations = new Map<
|
|
@@ -137,9 +163,75 @@ class TurnBudgetHandler extends BaseCallbackHandler {
|
|
|
137
163
|
>();
|
|
138
164
|
/** Tool-schema estimate per tool list, for this turn (see `toolListKey`). */
|
|
139
165
|
private readonly toolTokens = new Map<string, number>();
|
|
166
|
+
/** Open calls, for the trace: what they are, when they started, their heartbeat. */
|
|
167
|
+
private readonly open = new Map<
|
|
168
|
+
string,
|
|
169
|
+
{
|
|
170
|
+
what: string;
|
|
171
|
+
started: number;
|
|
172
|
+
heartbeat: ReturnType<typeof setInterval>;
|
|
173
|
+
}
|
|
174
|
+
>();
|
|
175
|
+
private readonly logger: Logger;
|
|
176
|
+
private readonly onAbort = (): void => this.closeAll();
|
|
140
177
|
|
|
141
178
|
constructor(private readonly options: BudgetedLlmOptions) {
|
|
142
179
|
super();
|
|
180
|
+
this.logger = options.logger ?? NOOP_LOGGER;
|
|
181
|
+
// A call the abort cut short may never report its end; its heartbeat
|
|
182
|
+
// must not outlive the turn (a live timer keeps the object resident).
|
|
183
|
+
options.signal?.addEventListener('abort', this.onAbort, { once: true });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
dispose(): void {
|
|
187
|
+
this.closeAll();
|
|
188
|
+
this.options.signal?.removeEventListener('abort', this.onAbort);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private traceStart(
|
|
192
|
+
runId: string,
|
|
193
|
+
extraParams: Record<string, unknown> | undefined,
|
|
194
|
+
tags: string[] | undefined,
|
|
195
|
+
metadata: Record<string, unknown> | undefined,
|
|
196
|
+
): void {
|
|
197
|
+
const role = metadata?.[ROLE_METADATA_KEY];
|
|
198
|
+
const invocation = extraParams?.invocation_params;
|
|
199
|
+
const invocationModel =
|
|
200
|
+
invocation && typeof invocation === 'object' && 'model' in invocation
|
|
201
|
+
? invocation.model
|
|
202
|
+
: undefined;
|
|
203
|
+
const model =
|
|
204
|
+
typeof invocationModel === 'string'
|
|
205
|
+
? invocationModel
|
|
206
|
+
: typeof metadata?.ls_model_name === 'string'
|
|
207
|
+
? metadata.ls_model_name
|
|
208
|
+
: 'unknown';
|
|
209
|
+
const what = `role=${typeof role === 'string' ? role : 'unknown'} model=${model}`;
|
|
210
|
+
this.logger.debug?.(
|
|
211
|
+
`[llm] start ${what} tags=${(tags ?? []).join(',') || '-'}`,
|
|
212
|
+
);
|
|
213
|
+
const started = Date.now();
|
|
214
|
+
const heartbeat = setInterval(() => {
|
|
215
|
+
this.logger.log(
|
|
216
|
+
`[llm] ${what} still running after ${Math.round((Date.now() - started) / 1000)} s`,
|
|
217
|
+
);
|
|
218
|
+
}, LLM_HEARTBEAT_MS);
|
|
219
|
+
this.open.set(runId, { what, started, heartbeat });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private traceEnd(runId: string, outcome: 'end' | 'error'): void {
|
|
223
|
+
const call = this.open.get(runId);
|
|
224
|
+
if (!call) return;
|
|
225
|
+
clearInterval(call.heartbeat);
|
|
226
|
+
this.open.delete(runId);
|
|
227
|
+
this.logger.debug?.(
|
|
228
|
+
`[llm] ${outcome} ${call.what} in ${Date.now() - call.started} ms`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private closeAll(): void {
|
|
233
|
+
for (const call of this.open.values()) clearInterval(call.heartbeat);
|
|
234
|
+
this.open.clear();
|
|
143
235
|
}
|
|
144
236
|
|
|
145
237
|
handleChatModelStart(
|
|
@@ -148,7 +240,7 @@ class TurnBudgetHandler extends BaseCallbackHandler {
|
|
|
148
240
|
runId: string,
|
|
149
241
|
_parentRunId?: string,
|
|
150
242
|
extraParams?: Record<string, unknown>,
|
|
151
|
-
|
|
243
|
+
tags?: string[],
|
|
152
244
|
metadata?: Record<string, unknown>,
|
|
153
245
|
): void {
|
|
154
246
|
// The handler can reach the same call twice (model-level and run-level
|
|
@@ -167,9 +259,12 @@ class TurnBudgetHandler extends BaseCallbackHandler {
|
|
|
167
259
|
tokens,
|
|
168
260
|
summary: metadata?.lc_source === SUMMARIZATION_SOURCE,
|
|
169
261
|
});
|
|
262
|
+
// Traced only once admitted: a refused call never starts a heartbeat.
|
|
263
|
+
this.traceStart(runId, extraParams, tags, metadata);
|
|
170
264
|
}
|
|
171
265
|
|
|
172
266
|
handleLLMEnd(output: LLMResult, runId: string): void {
|
|
267
|
+
this.traceEnd(runId, 'end');
|
|
173
268
|
const reservation = this.reservations.get(runId);
|
|
174
269
|
if (reservation === undefined) return;
|
|
175
270
|
this.reservations.delete(runId);
|
|
@@ -179,6 +274,7 @@ class TurnBudgetHandler extends BaseCallbackHandler {
|
|
|
179
274
|
}
|
|
180
275
|
|
|
181
276
|
handleLLMError(_error: unknown, runId: string): void {
|
|
277
|
+
this.traceEnd(runId, 'error');
|
|
182
278
|
const reservation = this.reservations.get(runId);
|
|
183
279
|
this.reservations.delete(runId);
|
|
184
280
|
// A failed call keeps its reservation: the provider may have consumed
|
|
@@ -186,7 +282,7 @@ class TurnBudgetHandler extends BaseCallbackHandler {
|
|
|
186
282
|
// is the exception: the summarizer swallows the failure, the turn goes on
|
|
187
283
|
// with its history unchanged and does not try again (summarization.ts),
|
|
188
284
|
// so its reservation — the whole history it was handed — is given back
|
|
189
|
-
//
|
|
285
|
+
// rather than reported as usage of the turn.
|
|
190
286
|
if (reservation?.summary)
|
|
191
287
|
this.options.budget.releaseModel(reservation.tokens);
|
|
192
288
|
}
|
|
@@ -214,8 +310,21 @@ export function budgetedLlm(
|
|
|
214
310
|
const callback = new TurnBudgetHandler(options);
|
|
215
311
|
return {
|
|
216
312
|
callback,
|
|
313
|
+
dispose: () => callback.dispose(),
|
|
217
314
|
get(role: ModelRole, params?: ChatOpenAIFields): BaseChatModel {
|
|
218
|
-
|
|
315
|
+
// The role rides on the model's run metadata, for the call trace. It
|
|
316
|
+
// goes in through the constructor params: `ChatOpenAI.bindTools`
|
|
317
|
+
// rebuilds the model from them, dropping anything set afterwards. The
|
|
318
|
+
// instance is stamped too, for adapters that ignore params.
|
|
319
|
+
const callerMetadata: unknown = params?.metadata;
|
|
320
|
+
const metadata = {
|
|
321
|
+
...(callerMetadata && typeof callerMetadata === 'object'
|
|
322
|
+
? callerMetadata
|
|
323
|
+
: {}),
|
|
324
|
+
[ROLE_METADATA_KEY]: String(role),
|
|
325
|
+
};
|
|
326
|
+
const model = adapter.get(role, { ...params, metadata });
|
|
327
|
+
model.metadata = { ...model.metadata, ...metadata };
|
|
219
328
|
attach(model, callback);
|
|
220
329
|
return model;
|
|
221
330
|
},
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
* (`context-window.ts`) so that they scale with the model:
|
|
4
4
|
*
|
|
5
5
|
* - `summarizeAtTokens` — the history is condensed once it is this large
|
|
6
|
-
* (default
|
|
7
|
-
* never above
|
|
6
|
+
* (default 60% of the window, whatever its size;
|
|
7
|
+
* never above the request cap nor above what the
|
|
8
|
+
* summarizer reads, which only binds below a
|
|
9
|
+
* ~34k window). The summary is written by the
|
|
10
|
+
* turn's own model (the `summarizer` role), so no
|
|
11
|
+
* second window bounds it;
|
|
8
12
|
* - `pruneAtTokens` — above this, old tool results are demoted to
|
|
9
13
|
* one-liners in the request (no model call, no
|
|
10
14
|
* state change) before the model sees them;
|
|
@@ -24,7 +28,7 @@ import { NOOP_LOGGER } from './utils';
|
|
|
24
28
|
export const CHARS_PER_TOKEN = 4;
|
|
25
29
|
|
|
26
30
|
export interface ContextKnobs {
|
|
27
|
-
/** Fraction of the window at which the history is summarized (default 0.
|
|
31
|
+
/** Fraction of the window at which the history is summarized (default 0.6). */
|
|
28
32
|
summarizeFraction: number;
|
|
29
33
|
/** Fraction of the window above which old tool results are pruned per request (default 0.35). */
|
|
30
34
|
pruneFraction: number;
|
|
@@ -47,7 +51,7 @@ export interface ContextKnobs {
|
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export const DEFAULT_CONTEXT_KNOBS: ContextKnobs = {
|
|
50
|
-
summarizeFraction: 0.
|
|
54
|
+
summarizeFraction: 0.6,
|
|
51
55
|
pruneFraction: 0.35,
|
|
52
56
|
resultCapFraction: 0.12,
|
|
53
57
|
resultCapMaxChars: 200_000,
|
|
@@ -66,13 +70,11 @@ export interface ContextBudget {
|
|
|
66
70
|
requestCapTokens: number;
|
|
67
71
|
outputReserveTokens: number;
|
|
68
72
|
/**
|
|
69
|
-
* The summarizer's
|
|
70
|
-
* the
|
|
71
|
-
*
|
|
73
|
+
* The summarizer's input limit (what it may read to write the summary):
|
|
74
|
+
* the main model's request cap less a margin for the summary prompt. The
|
|
75
|
+
* summarizer is the main model, so its request has the same room.
|
|
72
76
|
*/
|
|
73
77
|
summaryInputTokens: number;
|
|
74
|
-
/** The summarizing model's window, when the host resolved it. */
|
|
75
|
-
summaryWindowTokens?: number;
|
|
76
78
|
summarizeTriggerMessages?: number;
|
|
77
79
|
keepMessages: number;
|
|
78
80
|
}
|
|
@@ -170,34 +172,31 @@ function requestCapFor(
|
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
/**
|
|
173
|
-
* The budget for a turn on `resolution.model
|
|
174
|
-
* the
|
|
175
|
-
*
|
|
176
|
-
* by the main model's. Omitted, the main model's window alone bounds it.
|
|
175
|
+
* The budget for a turn on `resolution.model`, from that model's window
|
|
176
|
+
* alone: the summary is written by the same model (the `summarizer` role),
|
|
177
|
+
* so no other window bounds what it reads.
|
|
177
178
|
*/
|
|
178
179
|
export function contextBudgetFor(
|
|
179
180
|
resolution: ContextWindowResolution,
|
|
180
181
|
knobs: ContextKnobs = DEFAULT_CONTEXT_KNOBS,
|
|
181
|
-
summarizer?: ContextWindowResolution,
|
|
182
182
|
): ContextBudget {
|
|
183
183
|
const window = resolution.tokens;
|
|
184
184
|
const { requestCapTokens, outputReserveTokens } = requestCapFor(
|
|
185
185
|
window,
|
|
186
186
|
knobs,
|
|
187
187
|
);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
188
|
+
// The summarizer keeps only the newest `summaryInputTokens` of what it is
|
|
189
|
+
// asked to condense (LangChain trims with `strategy: 'last'`; the summary
|
|
190
|
+
// prompt needs its own room in the request) and drops the rest unread —
|
|
191
|
+
// the earlier summary first. Summarizing therefore starts no later than
|
|
192
|
+
// that limit, so the thread's memory is never lost unread. With the
|
|
193
|
+
// default knobs this only binds below a window of ~34.3k tokens (60% of
|
|
194
|
+
// the window exceeds 95% less the reply reserve and the prompt margin);
|
|
195
|
+
// above it the trigger is the plain fraction.
|
|
194
196
|
const summaryInputTokens = Math.max(
|
|
195
197
|
1_000,
|
|
196
|
-
|
|
198
|
+
requestCapTokens - SUMMARY_PROMPT_MARGIN_TOKENS,
|
|
197
199
|
);
|
|
198
|
-
// The summarizer keeps only the newest `summaryInputTokens` of what it is
|
|
199
|
-
// asked to condense and drops the rest unread — the earlier summary first.
|
|
200
|
-
// Summarizing starts no later than that, so nothing is lost unsummarized.
|
|
201
200
|
const summarizeAtTokens = Math.min(
|
|
202
201
|
Math.floor(window * knobs.summarizeFraction),
|
|
203
202
|
requestCapTokens,
|
|
@@ -220,7 +219,6 @@ export function contextBudgetFor(
|
|
|
220
219
|
requestCapTokens,
|
|
221
220
|
outputReserveTokens,
|
|
222
221
|
summaryInputTokens,
|
|
223
|
-
...(summarizer ? { summaryWindowTokens: summarizer.tokens } : {}),
|
|
224
222
|
...(knobs.summarizeTriggerMessages !== undefined
|
|
225
223
|
? { summarizeTriggerMessages: knobs.summarizeTriggerMessages }
|
|
226
224
|
: {}),
|
|
@@ -284,5 +282,5 @@ export function messageToolCallTokens(
|
|
|
284
282
|
}
|
|
285
283
|
|
|
286
284
|
export function describeBudget(b: ContextBudget): string {
|
|
287
|
-
return `model=${b.model} window=${b.windowTokens} (${b.origin}) summarizeAt=${b.summarizeAtTokens} pruneAt=${b.pruneAtTokens} resultCap=${b.resultCapChars}c requestCap=${b.requestCapTokens} reserve=${b.outputReserveTokens} summaryInput=${b.summaryInputTokens}
|
|
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}`;
|
|
288
286
|
}
|
|
@@ -8,22 +8,30 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Resolution order, first hit wins:
|
|
10
10
|
*
|
|
11
|
-
* 1.
|
|
12
|
-
* 2.
|
|
11
|
+
* 1. a window **learned** from the provider (below);
|
|
12
|
+
* 2. an operator override for the exact model id (`MODEL_CONTEXT_OVERRIDES`,
|
|
13
|
+
* optional — nothing needs one);
|
|
14
|
+
* 3. the OpenRouter `/models` listing (the same cached fetch that serves
|
|
13
15
|
* the price catalog) — for OpenRouter ids directly, and for BYO
|
|
14
16
|
* provider-native ids through the vendor prefix (`gpt-…` → `openai/gpt-…`);
|
|
15
|
-
*
|
|
17
|
+
* 4. the built-in table (`BUILTIN_CONTEXT_WINDOWS` in `llm.ts`: the
|
|
18
|
+
* curated catalog's `contextTokens` plus every other id the runtime
|
|
19
|
+
* sends), looked up the same way — so an unreachable catalogue does not
|
|
20
|
+
* shrink a known model to the default;
|
|
21
|
+
* 5. the deployment default (`MODEL_CONTEXT_TOKENS`, 100k unless set), only
|
|
22
|
+
* for a model nothing above knows.
|
|
16
23
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
24
|
+
* A window is **learned downwards**: when a provider rejects a request as
|
|
25
|
+
* too long and names its limit, that smaller number is kept for the model
|
|
26
|
+
* (persistently, when the host supplies a store) and wins over every other
|
|
27
|
+
* source from then on. A provider's error is the one source that is never
|
|
21
28
|
* wrong about its own limit; nothing is ever learned upwards.
|
|
22
29
|
*
|
|
23
30
|
* Modelled on Hermes Agent's `get_model_context_length` chain, trimmed to the
|
|
24
31
|
* sources this runtime has.
|
|
25
32
|
*/
|
|
26
33
|
import type { Logger } from '../plugin-api/types';
|
|
34
|
+
import { BUILTIN_CONTEXT_WINDOWS } from './llm';
|
|
27
35
|
import { NOOP_LOGGER } from './utils';
|
|
28
36
|
|
|
29
37
|
export const DEFAULT_CONTEXT_TOKENS = 100_000;
|
|
@@ -35,6 +43,7 @@ export type ContextWindowOrigin =
|
|
|
35
43
|
| 'override'
|
|
36
44
|
| 'learned'
|
|
37
45
|
| 'catalog'
|
|
46
|
+
| 'builtin'
|
|
38
47
|
| 'default';
|
|
39
48
|
|
|
40
49
|
export interface ContextWindowResolution {
|
|
@@ -42,7 +51,7 @@ export interface ContextWindowResolution {
|
|
|
42
51
|
model: string;
|
|
43
52
|
tokens: number;
|
|
44
53
|
origin: ContextWindowOrigin;
|
|
45
|
-
/** The catalog id that answered, when
|
|
54
|
+
/** The catalog (or built-in table) id that answered, when one did. */
|
|
46
55
|
catalogId?: string;
|
|
47
56
|
}
|
|
48
57
|
|
|
@@ -61,15 +70,20 @@ export interface ContextWindowResolverOptions {
|
|
|
61
70
|
config: ContextWindowConfig;
|
|
62
71
|
/** OpenRouter model id → context window (tokens). */
|
|
63
72
|
catalog: () => Promise<ReadonlyMap<string, number>>;
|
|
73
|
+
/**
|
|
74
|
+
* Known model id → context window (tokens), consulted after the catalogue
|
|
75
|
+
* with the same candidate ids. Default {@link BUILTIN_CONTEXT_WINDOWS}.
|
|
76
|
+
*/
|
|
77
|
+
builtin?: ReadonlyMap<string, number>;
|
|
64
78
|
learned?: LearnedWindowStore;
|
|
65
79
|
logger?: Logger;
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
/**
|
|
69
83
|
* `MODEL_CONTEXT_OVERRIDES="openai/gpt-5.6-luna=400000, gpt-5.6-terra=1000000"`
|
|
70
|
-
* — comma-separated `<model id>=<tokens>`; malformed entries are
|
|
71
|
-
* with a warning. `MODEL_CONTEXT_TOKENS` is the default for models
|
|
72
|
-
* else knows (min 16k).
|
|
84
|
+
* — optional, comma-separated `<model id>=<tokens>`; malformed entries are
|
|
85
|
+
* skipped with a warning. `MODEL_CONTEXT_TOKENS` is the default for models
|
|
86
|
+
* nothing else knows (min 16k).
|
|
73
87
|
*/
|
|
74
88
|
export function contextWindowConfig(
|
|
75
89
|
env: Record<string, unknown>,
|
|
@@ -212,8 +226,14 @@ export class ContextWindowResolver {
|
|
|
212
226
|
const base = await this.baseline(model, opts.byoProvider);
|
|
213
227
|
const learned = await this.learned(model);
|
|
214
228
|
if (learned !== undefined && learned < base.tokens) {
|
|
229
|
+
this.logger.debug?.(
|
|
230
|
+
`[context] ${model}: window ${learned} (learned; ${base.origin} says ${base.tokens})`,
|
|
231
|
+
);
|
|
215
232
|
return { ...base, tokens: learned, origin: 'learned' };
|
|
216
233
|
}
|
|
234
|
+
this.logger.debug?.(
|
|
235
|
+
`[context] ${model}: window ${base.tokens} (${base.origin}${base.catalogId ? ` ${base.catalogId}` : ''})`,
|
|
236
|
+
);
|
|
217
237
|
return base;
|
|
218
238
|
}
|
|
219
239
|
|
|
@@ -235,13 +255,20 @@ export class ContextWindowResolver {
|
|
|
235
255
|
`[context] model catalog unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
236
256
|
);
|
|
237
257
|
}
|
|
258
|
+
const candidates = catalogCandidates(model, byoProvider);
|
|
238
259
|
if (catalog) {
|
|
239
|
-
for (const candidate of
|
|
260
|
+
for (const candidate of candidates) {
|
|
240
261
|
const tokens = catalog.get(candidate);
|
|
241
262
|
if (tokens !== undefined && isWindow(tokens))
|
|
242
263
|
return { model, tokens, origin: 'catalog', catalogId: candidate };
|
|
243
264
|
}
|
|
244
265
|
}
|
|
266
|
+
const builtin = this.options.builtin ?? BUILTIN_CONTEXT_WINDOWS;
|
|
267
|
+
for (const candidate of candidates) {
|
|
268
|
+
const tokens = builtin.get(candidate);
|
|
269
|
+
if (tokens !== undefined && isWindow(tokens))
|
|
270
|
+
return { model, tokens, origin: 'builtin', catalogId: candidate };
|
|
271
|
+
}
|
|
245
272
|
return { model, tokens: config.defaultTokens, origin: 'default' };
|
|
246
273
|
}
|
|
247
274
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a validated `domain.md` frontmatter: the document index entries and
|
|
3
|
+
* the brief (the bounded slice of the frontmatter the prompt carries).
|
|
4
|
+
*/
|
|
5
|
+
import type { DomainDocument } from '@ixo/domain.md/workers';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
const documentEntry = z.object({
|
|
9
|
+
id: z.string(),
|
|
10
|
+
uri: z.string().nullable(),
|
|
11
|
+
cid: z.string().nullable(),
|
|
12
|
+
required_for_tasks: z.array(z.string()).optional(),
|
|
13
|
+
freshness: z
|
|
14
|
+
.object({
|
|
15
|
+
last_verified: z.string().nullable(),
|
|
16
|
+
max_age: z.string().nullable(),
|
|
17
|
+
})
|
|
18
|
+
.optional(),
|
|
19
|
+
role: z.string(),
|
|
20
|
+
media_type: z.string(),
|
|
21
|
+
disclosure_pass: z.number(),
|
|
22
|
+
sensitivity: z.string(),
|
|
23
|
+
access_policy: z.string(),
|
|
24
|
+
agent_use: z.object({
|
|
25
|
+
read: z.boolean(),
|
|
26
|
+
cite: z.boolean(),
|
|
27
|
+
summarize: z.boolean(),
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
export type DomainDocumentEntry = z.infer<typeof documentEntry>;
|
|
31
|
+
|
|
32
|
+
const entriesSchema = z.object({ entries: z.array(documentEntry) });
|
|
33
|
+
|
|
34
|
+
/** The document index; empty when the frontmatter carries none (or a malformed one). */
|
|
35
|
+
export function entries(
|
|
36
|
+
document: DomainDocument | undefined,
|
|
37
|
+
): DomainDocumentEntry[] {
|
|
38
|
+
const result = entriesSchema.safeParse(document?.frontmatter.documents);
|
|
39
|
+
return result.success ? result.data.entries : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `value` as a plain object, or `{}`. */
|
|
43
|
+
export function record(value: unknown): Record<string, unknown> {
|
|
44
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
45
|
+
? { ...value }
|
|
46
|
+
: {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A linked document is private unless both its access policy and its sensitivity are `public`. */
|
|
50
|
+
export function isPrivateEntry(entry: DomainDocumentEntry): boolean {
|
|
51
|
+
return entry.access_policy !== 'public' || entry.sensitivity !== 'public';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The parts of the frontmatter the model needs to orient itself: purpose and
|
|
56
|
+
* boundary, constitution, sources of truth, default agent mode, controllers,
|
|
57
|
+
* baseline rights, privacy, the critical don'ts and the document index
|
|
58
|
+
* (without URIs or CIDs). Serialized once per CID by the resolver.
|
|
59
|
+
*/
|
|
60
|
+
export function briefBody(document: DomainDocument): Record<string, unknown> {
|
|
61
|
+
const front = document.frontmatter;
|
|
62
|
+
return {
|
|
63
|
+
domain: front.domain,
|
|
64
|
+
constitution: front.constitution,
|
|
65
|
+
source_of_truth: front.source_of_truth,
|
|
66
|
+
agent_default_mode: front.agent_default_mode,
|
|
67
|
+
controllers: record(front.controllers).summary,
|
|
68
|
+
rights: record(front.rights).agent_baseline,
|
|
69
|
+
privacy: front.privacy,
|
|
70
|
+
critical_do_not: front.critical_do_not,
|
|
71
|
+
documents: entries(document).map((entry) => ({
|
|
72
|
+
id: entry.id,
|
|
73
|
+
role: entry.role,
|
|
74
|
+
disclosure_pass: entry.disclosure_pass,
|
|
75
|
+
required_for_tasks: entry.required_for_tasks,
|
|
76
|
+
agent_use: entry.agent_use,
|
|
77
|
+
access_policy: entry.access_policy,
|
|
78
|
+
sensitivity: entry.sensitivity,
|
|
79
|
+
})),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* ISO 8601 durations of days and time parts (`P180D`, `PT12H`, `P1DT2H`) in
|
|
85
|
+
* milliseconds; `undefined` for any other form (years, months and weeks are
|
|
86
|
+
* calendar-dependent and refused).
|
|
87
|
+
*/
|
|
88
|
+
export function durationMs(value: string): number | undefined {
|
|
89
|
+
const match = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(
|
|
90
|
+
value,
|
|
91
|
+
);
|
|
92
|
+
if (!match) return undefined;
|
|
93
|
+
const ms =
|
|
94
|
+
(Number(match[1] ?? 0) * 86_400 +
|
|
95
|
+
Number(match[2] ?? 0) * 3_600 +
|
|
96
|
+
Number(match[3] ?? 0) * 60 +
|
|
97
|
+
Number(match[4] ?? 0)) *
|
|
98
|
+
1000;
|
|
99
|
+
return ms > 0 ? ms : undefined;
|
|
100
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding codes are diagnostics that reach the prompt block, the
|
|
3
|
+
* `router_update` stream and `domain_context_runs`, so every list of them is
|
|
4
|
+
* bounded where it enters a snapshot: a hostile index can make the validator
|
|
5
|
+
* report one finding per bad entry, tens of thousands in all.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Most entries one finding list holds, `findings-truncated` included. */
|
|
9
|
+
export const MAX_FINDINGS = 32;
|
|
10
|
+
/** Last entry of a list that was cut to {@link MAX_FINDINGS}. */
|
|
11
|
+
export const FINDINGS_TRUNCATED = 'findings-truncated';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `codes` without duplicates, in first-seen order, at most
|
|
15
|
+
* {@link MAX_FINDINGS} entries. A cut list (or one that already carried
|
|
16
|
+
* `findings-truncated`) keeps its first codes and ends with
|
|
17
|
+
* `findings-truncated`, so a bounded list can be extended and bounded again.
|
|
18
|
+
*/
|
|
19
|
+
export function boundFindings(codes: Iterable<string>): string[] {
|
|
20
|
+
const unique = new Set(codes);
|
|
21
|
+
const truncated = unique.delete(FINDINGS_TRUNCATED);
|
|
22
|
+
const list = [...unique];
|
|
23
|
+
if (!truncated && list.length <= MAX_FINDINGS) return list;
|
|
24
|
+
return [...list.slice(0, MAX_FINDINGS - 1), FINDINGS_TRUNCATED];
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
DocumentRequest,
|
|
3
|
+
DomainAnchor,
|
|
4
|
+
DomainCapsuleInspection,
|
|
5
|
+
DomainContextOptions,
|
|
6
|
+
DomainContextPins,
|
|
7
|
+
DomainContextProvenance,
|
|
8
|
+
DomainDocumentRead,
|
|
9
|
+
DomainSnapshot,
|
|
10
|
+
DomainTransport,
|
|
11
|
+
} from './types';
|
|
12
|
+
export {
|
|
13
|
+
ANCHOR_LOOKUP_TIMEOUT_MS,
|
|
14
|
+
DEFAULT_ANCHOR_TTL_MS,
|
|
15
|
+
DomainContextResolver,
|
|
16
|
+
PINNED_ANCHOR_SOURCE,
|
|
17
|
+
} from './resolver';
|
|
18
|
+
export { createDomainTransport, DOCUMENT_READ_TIMEOUT_MS } from './transport';
|
|
19
|
+
export {
|
|
20
|
+
DEFAULT_PASS1,
|
|
21
|
+
DOCUMENT_PAGE_CHARS,
|
|
22
|
+
DOMAIN_CONTEXT_CLOSE,
|
|
23
|
+
DOMAIN_CONTEXT_OPEN,
|
|
24
|
+
DOMAIN_CONTEXT_PRECEDENCE,
|
|
25
|
+
prepareDomainContext,
|
|
26
|
+
provenance,
|
|
27
|
+
RESOLUTION_TIMEOUT_MS,
|
|
28
|
+
} from './turn-context';
|