@ixo/oracle-runtime-workers 0.38.0 → 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.
- package/package.json +2 -2
- package/src/channels/replies.ts +8 -4
- package/src/core/capability-expiry.ts +62 -0
- package/src/core/capability-router.ts +126 -95
- package/src/core/context-budget.ts +116 -4
- package/src/core/domain-context/document.ts +74 -2
- package/src/core/domain-context/index.ts +1 -0
- package/src/core/domain-context/resolver.ts +9 -6
- package/src/core/domain-context/turn-context.ts +62 -13
- package/src/core/domain-context/types.ts +17 -0
- package/src/core/env.ts +21 -2
- package/src/core/index.ts +4 -0
- package/src/core/llm.ts +29 -0
- package/src/core/main-agent-types.ts +45 -4
- package/src/core/main-agent.ts +210 -122
- package/src/core/manifest.ts +109 -24
- package/src/core/mcp-result.ts +56 -0
- package/src/core/meta-tools.ts +258 -110
- package/src/core/middlewares/capability-gate.ts +77 -28
- package/src/core/middlewares/context-guard.ts +154 -21
- package/src/core/middlewares/result-cap.ts +1 -1
- package/src/core/middlewares/summarization.ts +9 -0
- package/src/core/middlewares/tool-marks.ts +11 -2
- package/src/core/middlewares/turn-boundary.ts +22 -12
- package/src/core/operating-guides.ts +126 -0
- package/src/core/plugins/skills/skills-tools.ts +6 -31
- package/src/core/plugins/skills/skills.plugin.ts +4 -3
- package/src/core/plugins/weather/weather.plugin.ts +1 -0
- package/src/core/prompt-composer.ts +188 -111
- package/src/core/read-result-tool.ts +48 -14
- package/src/core/registries.ts +13 -5
- package/src/core/state.ts +4 -3
- package/src/core/sub-agent-fallback.ts +1 -0
- package/src/core/subagent-as-tool.ts +4 -1
- package/src/core/tool-access.ts +3 -32
- package/src/core/wrap-plugin-tool.ts +2 -1
- package/src/do/contracts.ts +13 -1
- package/src/do/transcript.ts +40 -11
- package/src/do/turn-metadata.ts +16 -4
- package/src/do/user-oracle-do.ts +132 -59
- package/src/index.ts +1 -0
- package/src/memory/session-history-indexer.ts +60 -14
- package/src/plugin-api/oracle-plugin.ts +4 -5
- package/src/plugin-api/types.ts +32 -3
- package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
- package/src/plugins/agui/agui.plugin.ts +1 -0
- package/src/plugins/compact-schema.ts +80 -0
- package/src/plugins/composio/composio.plugin.ts +1 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
- package/src/plugins/editor/editor.plugin.ts +4 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
- package/src/plugins/flows/flows.plugin.ts +37 -6
- package/src/plugins/flows/prompts.ts +1 -1
- package/src/plugins/flows/tools/authoring.ts +5 -7
- package/src/plugins/flows/tools/discovery.ts +1 -4
- package/src/plugins/flows/tools/forms.ts +2 -8
- package/src/plugins/flows/tools/linkage.ts +1 -4
- package/src/plugins/flows/tools/settings.ts +1 -4
- package/src/plugins/flows/translator.ts +16 -5
- package/src/plugins/flows/types.ts +27 -48
- package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
- package/src/plugins/index.ts +2 -2
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
- package/src/plugins/ixo-chain/kinds/core.ts +2 -2
- package/src/plugins/ixo-chain/kinds/types.ts +34 -5
- package/src/plugins/ixo-chain/queries.ts +10 -1
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
- package/src/plugins/matrix-group-chats/index.ts +1 -0
- package/src/plugins/memory/memory-tools.ts +4 -4
- package/src/plugins/memory/memory.plugin.ts +27 -1
- package/src/plugins/memory/oracle-document-tools.ts +24 -49
- package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
- package/src/plugins/portal/portal.plugin.ts +1 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
- package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
- package/src/plugins/tasks/manifest.ts +1 -0
- package/src/plugins/tasks/tasks-tools.ts +14 -19
- package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
- package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
- package/src/plugins/vfs/vfs-tools.ts +6 -6
- package/src/plugins/vfs/vfs.plugin.ts +4 -5
- package/src/plugins/yoma/yoma.plugin.ts +1 -0
- package/src/sqlite/serialization.ts +12 -0
- package/src/sqlite/sqlite-saver.ts +17 -0
- package/src/tasks/schedule-input.ts +5 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.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": {
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"protobufjs": "^7.6.5",
|
|
68
68
|
"@ixo/topic-protocol": "1.0.0-rc.8",
|
|
69
69
|
"survey-core": "2.3.11",
|
|
70
|
+
"@ixo/common": "1.8.0",
|
|
70
71
|
"@ixo/ucan": "2.3.0",
|
|
71
|
-
"@ixo/common": "1.7.2",
|
|
72
72
|
"@ixo/ixo-transaction": "0.2.0"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
package/src/channels/replies.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BaseMessage, MessageContent } from '@langchain/core/messages';
|
|
2
|
+
import { TURN_GUIDE_NOTE_KWARG } from '../core/operating-guides';
|
|
2
3
|
import {
|
|
3
4
|
stripTurnTimeNote,
|
|
4
5
|
TURN_TIME_NOTE_KWARG,
|
|
@@ -9,7 +10,7 @@ import type { ChannelFileNotice, ChannelReplyContext } from './contract';
|
|
|
9
10
|
* `additional_kwargs` key holding the exact quoted-reply note a channel
|
|
10
11
|
* message was sent with, so readers can take it off the stored content again
|
|
11
12
|
* (see `userContent`). The note sits between the time note and what the
|
|
12
|
-
* person typed: `time note \n\n reply note \n\n message`.
|
|
13
|
+
* person typed: `time note \n\n guide note \n\n reply note \n\n message`.
|
|
13
14
|
*/
|
|
14
15
|
export const CHANNEL_REPLY_NOTE_KWARG = 'channel_reply_note';
|
|
15
16
|
|
|
@@ -84,8 +85,8 @@ export function isDeliveryNoticeMessage(message: BaseMessage): boolean {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
|
-
* The content as the person sent it: without the runtime's time
|
|
88
|
-
* without the quoted-reply note and without a delivery notice. Every reader
|
|
88
|
+
* The content as the person sent it: without the runtime's time and guide
|
|
89
|
+
* notes, without the quoted-reply note and without a delivery notice. Every reader
|
|
89
90
|
* that treats a human message as the person's own words (the transcript,
|
|
90
91
|
* memory indexing, the evidence a proposal is checked against) goes through
|
|
91
92
|
* here, so quoted text or a gateway's notice is never taken for something
|
|
@@ -97,7 +98,10 @@ export function userContent(
|
|
|
97
98
|
): MessageContent {
|
|
98
99
|
return stripTurnTimeNote(
|
|
99
100
|
stripTurnTimeNote(
|
|
100
|
-
stripTurnTimeNote(
|
|
101
|
+
stripTurnTimeNote(
|
|
102
|
+
stripTurnTimeNote(content, kwargs?.[TURN_TIME_NOTE_KWARG]),
|
|
103
|
+
kwargs?.[TURN_GUIDE_NOTE_KWARG],
|
|
104
|
+
),
|
|
101
105
|
kwargs?.[CHANNEL_REPLY_NOTE_KWARG],
|
|
102
106
|
),
|
|
103
107
|
kwargs?.[CHANNEL_DELIVERY_NOTICE_KWARG],
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long an on-demand capability stays loaded on a thread.
|
|
3
|
+
*
|
|
4
|
+
* A loaded plugin's tools and operating guide go out with every model call,
|
|
5
|
+
* so a plugin the conversation has moved on from is paid for on every call.
|
|
6
|
+
* Taking it away changes the tool list and the system prompt, which costs a
|
|
7
|
+
* full cache miss on a warm thread: the request's whole prefix is read again
|
|
8
|
+
* at the uncached price, far more than the plugin's tools cost at the cached
|
|
9
|
+
* one. On a thread that has gone quiet long enough for the provider's cache
|
|
10
|
+
* to expire the miss has already happened, so the runtime only ever unloads
|
|
11
|
+
* a plugin on such a turn, and only one the last `maxIdleTurns` turns did not
|
|
12
|
+
* touch. The model can load it again: the capability gate's refusal says how.
|
|
13
|
+
*
|
|
14
|
+
* Use is read off the thread itself: a call to one of the plugin's tools, or
|
|
15
|
+
* a `load_capability` naming it. Nothing is stored, so a summary that
|
|
16
|
+
* condensed a plugin's last use makes it idle, which is what it is.
|
|
17
|
+
*/
|
|
18
|
+
import { AIMessage, type BaseMessage } from '@langchain/core/messages';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { LOAD_CAPABILITY_TOOL_NAME } from './meta-tools';
|
|
21
|
+
import { turnOpenings } from './middlewares/turn-boundary';
|
|
22
|
+
|
|
23
|
+
const loadArgs = z.object({ names: z.array(z.string()) });
|
|
24
|
+
|
|
25
|
+
/** The plugins one model response touched. */
|
|
26
|
+
function pluginsTouched(
|
|
27
|
+
message: AIMessage,
|
|
28
|
+
pluginOf: (toolName: string) => string | undefined,
|
|
29
|
+
): string[] {
|
|
30
|
+
return (message.tool_calls ?? []).flatMap((call) => {
|
|
31
|
+
if (call.name === LOAD_CAPABILITY_TOOL_NAME) {
|
|
32
|
+
const args = loadArgs.safeParse(call.args);
|
|
33
|
+
return args.success ? args.data.names : [];
|
|
34
|
+
}
|
|
35
|
+
const plugin = pluginOf(call.name);
|
|
36
|
+
return plugin ? [plugin] : [];
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Which of the `loaded` plugins no turn among the last `maxIdleTurns` of the
|
|
42
|
+
* thread touched. `messages` are the finished turns, the current one not yet
|
|
43
|
+
* among them; a thread with fewer turns than that unloads nothing.
|
|
44
|
+
*/
|
|
45
|
+
export function idlePlugins(input: {
|
|
46
|
+
messages: readonly BaseMessage[];
|
|
47
|
+
loaded: readonly string[];
|
|
48
|
+
pluginOf: (toolName: string) => string | undefined;
|
|
49
|
+
maxIdleTurns: number;
|
|
50
|
+
}): string[] {
|
|
51
|
+
const { messages, loaded, pluginOf, maxIdleTurns } = input;
|
|
52
|
+
if (maxIdleTurns <= 0) return [];
|
|
53
|
+
const openings = turnOpenings(messages);
|
|
54
|
+
const recent = openings[openings.length - maxIdleTurns];
|
|
55
|
+
if (recent === undefined) return [];
|
|
56
|
+
const touched = new Set<string>();
|
|
57
|
+
for (const message of messages.slice(recent))
|
|
58
|
+
if (AIMessage.isInstance(message))
|
|
59
|
+
for (const plugin of pluginsTouched(message, pluginOf))
|
|
60
|
+
touched.add(plugin);
|
|
61
|
+
return loaded.filter((plugin) => !touched.has(plugin));
|
|
62
|
+
}
|
|
@@ -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
|
-
|
|
7
|
-
|
|
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,
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
24
|
-
* and
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
|
|
53
|
-
/**
|
|
54
|
-
|
|
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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
.
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
`
|
|
222
|
-
`
|
|
223
|
-
`
|
|
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=${
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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}
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
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
|
}
|