@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +310 -0
- package/src/channels/contract.ts +246 -0
- package/src/channels/turns.ts +280 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +2003 -274
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +562 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn's LLM adapter, metered: every model it hands out carries a
|
|
3
|
+
* callback that reserves the call against the `TurnBudget` before the
|
|
4
|
+
* provider is contacted and settles the reservation to the provider's
|
|
5
|
+
* reported usage when the call ends. Helper models (summarizer, attachment
|
|
6
|
+
* extraction, a plugin's own `ctx.llm.get`) are covered the same way as the
|
|
7
|
+
* main model, since they all come from this adapter.
|
|
8
|
+
*
|
|
9
|
+
* The same handler is also put in the graph's run config (`callbacks`) so a
|
|
10
|
+
* model the host constructed elsewhere and passed in directly is still
|
|
11
|
+
* charged; a call reported through both paths is counted once.
|
|
12
|
+
*/
|
|
13
|
+
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
|
|
14
|
+
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
|
15
|
+
import type { LLMResult } from '@langchain/core/outputs';
|
|
16
|
+
import type { ChatOpenAIFields, ModelRole } from '../plugin-api/types';
|
|
17
|
+
import {
|
|
18
|
+
estimateContentTokens,
|
|
19
|
+
messageContentTokens,
|
|
20
|
+
messageToolCallTokens,
|
|
21
|
+
} from './context-budget';
|
|
22
|
+
import type { LlmAdapter } from './runtime-context';
|
|
23
|
+
import type { TurnBudget } from './turn-budget';
|
|
24
|
+
|
|
25
|
+
export interface BudgetedLlmOptions {
|
|
26
|
+
budget: TurnBudget;
|
|
27
|
+
/** Tokens reserved for the reply of each call (the context budget's output reserve). */
|
|
28
|
+
outputReserveTokens: number;
|
|
29
|
+
/** The turn's abort signal: a call after the abort is refused before it starts. */
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BudgetedLlm extends LlmAdapter {
|
|
34
|
+
/** Put this in the run config's `callbacks` as well. */
|
|
35
|
+
readonly callback: BaseCallbackHandler;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Key of a request's tool list for the per-turn schema estimate: the tool
|
|
40
|
+
* names in order. Within one turn a name always carries the same schema (the
|
|
41
|
+
* tools are bound when the turn's graph is built), so the names identify
|
|
42
|
+
* the list; anything without a recognisable name is not cached.
|
|
43
|
+
*/
|
|
44
|
+
function toolListKey(tools: unknown): string | undefined {
|
|
45
|
+
if (!Array.isArray(tools)) return undefined;
|
|
46
|
+
const names: string[] = [];
|
|
47
|
+
for (const entry of tools) {
|
|
48
|
+
const fn: unknown =
|
|
49
|
+
entry && typeof entry === 'object' && 'function' in entry
|
|
50
|
+
? entry.function
|
|
51
|
+
: entry;
|
|
52
|
+
const name: unknown =
|
|
53
|
+
fn && typeof fn === 'object' && 'name' in fn ? fn.name : undefined;
|
|
54
|
+
if (typeof name !== 'string') return undefined;
|
|
55
|
+
names.push(name);
|
|
56
|
+
}
|
|
57
|
+
return names.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What one model call sends, reduced to what costs tokens. Message estimates
|
|
62
|
+
* are memoised per message (`messageContentTokens`), the tool schemas per
|
|
63
|
+
* tool list in `toolTokens`, so a long history is not re-serialised on every
|
|
64
|
+
* step of the turn.
|
|
65
|
+
*/
|
|
66
|
+
function estimateRequestTokens(
|
|
67
|
+
messages: LLMResultMessages,
|
|
68
|
+
invocationParams: unknown,
|
|
69
|
+
toolTokens: Map<string, number>,
|
|
70
|
+
): number {
|
|
71
|
+
let tokens = 0;
|
|
72
|
+
for (const batch of messages)
|
|
73
|
+
for (const message of batch) {
|
|
74
|
+
tokens += messageContentTokens(message) + 4;
|
|
75
|
+
if ('tool_calls' in message && Array.isArray(message.tool_calls))
|
|
76
|
+
tokens += messageToolCallTokens(message, message.tool_calls);
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
invocationParams &&
|
|
80
|
+
typeof invocationParams === 'object' &&
|
|
81
|
+
'tools' in invocationParams
|
|
82
|
+
) {
|
|
83
|
+
const { tools } = invocationParams;
|
|
84
|
+
const key = toolListKey(tools);
|
|
85
|
+
let schema = key === undefined ? undefined : toolTokens.get(key);
|
|
86
|
+
if (schema === undefined) {
|
|
87
|
+
schema = estimateContentTokens(tools);
|
|
88
|
+
if (key !== undefined) toolTokens.set(key, schema);
|
|
89
|
+
}
|
|
90
|
+
tokens += schema;
|
|
91
|
+
}
|
|
92
|
+
return tokens;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The summarizer tags its model call with this `lc_source` (LangChain's summarization middleware). */
|
|
96
|
+
const SUMMARIZATION_SOURCE = 'summarization';
|
|
97
|
+
|
|
98
|
+
type LLMResultMessages = Parameters<
|
|
99
|
+
NonNullable<BaseCallbackHandler['handleChatModelStart']>
|
|
100
|
+
>[1];
|
|
101
|
+
|
|
102
|
+
function numberField(value: unknown, key: string): number | undefined {
|
|
103
|
+
if (!value || typeof value !== 'object' || !(key in value)) return undefined;
|
|
104
|
+
const n = Reflect.get(value, key);
|
|
105
|
+
return typeof n === 'number' && Number.isFinite(n) ? n : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Provider usage of one call: `llmOutput.tokenUsage` (OpenAI-compatible) or the message's `usage_metadata`. */
|
|
109
|
+
function reportedTotalTokens(output: LLMResult): number | undefined {
|
|
110
|
+
const usage: unknown = output.llmOutput?.tokenUsage;
|
|
111
|
+
const total = numberField(usage, 'totalTokens');
|
|
112
|
+
if (total !== undefined) return total;
|
|
113
|
+
const prompt = numberField(usage, 'promptTokens');
|
|
114
|
+
const completion = numberField(usage, 'completionTokens');
|
|
115
|
+
if (prompt !== undefined && completion !== undefined)
|
|
116
|
+
return prompt + completion;
|
|
117
|
+
const generation: unknown = output.generations[0]?.[0];
|
|
118
|
+
const message =
|
|
119
|
+
generation && typeof generation === 'object' && 'message' in generation
|
|
120
|
+
? generation.message
|
|
121
|
+
: undefined;
|
|
122
|
+
const meta =
|
|
123
|
+
message && typeof message === 'object' && 'usage_metadata' in message
|
|
124
|
+
? message.usage_metadata
|
|
125
|
+
: undefined;
|
|
126
|
+
return numberField(meta, 'total_tokens');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
class TurnBudgetHandler extends BaseCallbackHandler {
|
|
130
|
+
name = 'TurnBudgetHandler';
|
|
131
|
+
/** A refused reservation must fail the model call, not be logged and ignored. */
|
|
132
|
+
raiseError = true;
|
|
133
|
+
awaitHandlers = true;
|
|
134
|
+
private readonly reservations = new Map<
|
|
135
|
+
string,
|
|
136
|
+
{ tokens: number; summary: boolean }
|
|
137
|
+
>();
|
|
138
|
+
/** Tool-schema estimate per tool list, for this turn (see `toolListKey`). */
|
|
139
|
+
private readonly toolTokens = new Map<string, number>();
|
|
140
|
+
|
|
141
|
+
constructor(private readonly options: BudgetedLlmOptions) {
|
|
142
|
+
super();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
handleChatModelStart(
|
|
146
|
+
_llm: unknown,
|
|
147
|
+
messages: LLMResultMessages,
|
|
148
|
+
runId: string,
|
|
149
|
+
_parentRunId?: string,
|
|
150
|
+
extraParams?: Record<string, unknown>,
|
|
151
|
+
_tags?: string[],
|
|
152
|
+
metadata?: Record<string, unknown>,
|
|
153
|
+
): void {
|
|
154
|
+
// The handler can reach the same call twice (model-level and run-level
|
|
155
|
+
// registration); the first registration charges it.
|
|
156
|
+
if (this.reservations.has(runId)) return;
|
|
157
|
+
const tokens = this.options.budget.reserveModel(
|
|
158
|
+
estimateRequestTokens(
|
|
159
|
+
messages,
|
|
160
|
+
extraParams?.invocation_params,
|
|
161
|
+
this.toolTokens,
|
|
162
|
+
),
|
|
163
|
+
this.options.outputReserveTokens,
|
|
164
|
+
this.options.signal,
|
|
165
|
+
);
|
|
166
|
+
this.reservations.set(runId, {
|
|
167
|
+
tokens,
|
|
168
|
+
summary: metadata?.lc_source === SUMMARIZATION_SOURCE,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
handleLLMEnd(output: LLMResult, runId: string): void {
|
|
173
|
+
const reservation = this.reservations.get(runId);
|
|
174
|
+
if (reservation === undefined) return;
|
|
175
|
+
this.reservations.delete(runId);
|
|
176
|
+
const reported = reportedTotalTokens(output);
|
|
177
|
+
if (reported !== undefined)
|
|
178
|
+
this.options.budget.settleModel(reservation.tokens, reported);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
handleLLMError(_error: unknown, runId: string): void {
|
|
182
|
+
const reservation = this.reservations.get(runId);
|
|
183
|
+
this.reservations.delete(runId);
|
|
184
|
+
// A failed call keeps its reservation: the provider may have consumed
|
|
185
|
+
// the input before failing, and a retry reserves again. A failed summary
|
|
186
|
+
// is the exception: the summarizer swallows the failure, the turn goes on
|
|
187
|
+
// with its history unchanged and does not try again (summarization.ts),
|
|
188
|
+
// so its reservation — the whole history it was handed — is given back
|
|
189
|
+
// instead of starving the rest of the turn.
|
|
190
|
+
if (reservation?.summary)
|
|
191
|
+
this.options.budget.releaseModel(reservation.tokens);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function attach(model: BaseChatModel, handler: BaseCallbackHandler): void {
|
|
196
|
+
const existing = model.callbacks;
|
|
197
|
+
if (existing === undefined) {
|
|
198
|
+
model.callbacks = [handler];
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (Array.isArray(existing)) {
|
|
202
|
+
if (!existing.includes(handler)) model.callbacks = [...existing, handler];
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const manager = existing.copy();
|
|
206
|
+
manager.addHandler(handler);
|
|
207
|
+
model.callbacks = manager;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function budgetedLlm(
|
|
211
|
+
adapter: LlmAdapter,
|
|
212
|
+
options: BudgetedLlmOptions,
|
|
213
|
+
): BudgetedLlm {
|
|
214
|
+
const callback = new TurnBudgetHandler(options);
|
|
215
|
+
return {
|
|
216
|
+
callback,
|
|
217
|
+
get(role: ModelRole, params?: ChatOpenAIFields): BaseChatModel {
|
|
218
|
+
const model = adapter.get(role, params);
|
|
219
|
+
attach(model, callback);
|
|
220
|
+
return model;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AmbiguousDecisionProviderError,
|
|
3
|
+
CAPABILITY_ROUTER_MODES,
|
|
4
|
+
CAPABILITY_ROUTE_DECISION_NAME,
|
|
5
|
+
DecisionProviderUnavailableError,
|
|
6
|
+
capabilityRouteDecision,
|
|
7
|
+
decideCapabilityRoute,
|
|
8
|
+
toRoutableCapabilities,
|
|
9
|
+
type CapabilityRouterMode,
|
|
10
|
+
type DecisionEvaluator,
|
|
11
|
+
type DecisionTraceOptions,
|
|
12
|
+
type RoutableCapability,
|
|
13
|
+
} from '@ixo/common/ai/decisions';
|
|
14
|
+
import type { Logger } from '../plugin-api/types';
|
|
15
|
+
import { unmetRequirements } from './manifest';
|
|
16
|
+
import type { RegisteredManifest } from './registries';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Predicts, before the first model call of a turn, which on-demand plugin the
|
|
20
|
+
* user's message needs and preloads that plugin's tools for the turn, so the
|
|
21
|
+
* model does not spend a round trip on `load_capability`.
|
|
22
|
+
*
|
|
23
|
+
* The preload is one-turn state only: it is admitted by the capability gate
|
|
24
|
+
* and visible as loaded to tool handlers, but never written to the graph's
|
|
25
|
+
* `loadedPlugins` channel. The router fails open — any evaluation failure
|
|
26
|
+
* preloads nothing and the turn proceeds exactly as without a router.
|
|
27
|
+
*/
|
|
28
|
+
export interface CapabilityRouterOptions {
|
|
29
|
+
evaluator: DecisionEvaluator;
|
|
30
|
+
logger: Logger;
|
|
31
|
+
/**
|
|
32
|
+
* Keeps a shadow evaluation alive after the turn has moved on
|
|
33
|
+
* (`ctx.waitUntil` on Workers). Without it the promise is merely detached.
|
|
34
|
+
*/
|
|
35
|
+
background?: (work: Promise<unknown>) => void;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CapabilityRouteTurn {
|
|
40
|
+
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
|
+
/**
|
|
46
|
+
* Plugins the user's delegation leaves with nothing to use (all of their
|
|
47
|
+
* tools admin-plane and not granted): never offered to the router. A
|
|
48
|
+
* function is called only when the router actually evaluates (not when
|
|
49
|
+
* it is off, or the message is empty), so the host does not compute the
|
|
50
|
+
* set for nothing.
|
|
51
|
+
*/
|
|
52
|
+
hidden?: ReadonlySet<string> | (() => Promise<ReadonlySet<string>>);
|
|
53
|
+
/**
|
|
54
|
+
* The user's delegation, as `ctx.ucan.hasCapability`: a plugin whose
|
|
55
|
+
* `manifest.requires` it does not grant is never offered (the gate would
|
|
56
|
+
* refuse every one of its tools). Omitted, `requires` is not checked.
|
|
57
|
+
*/
|
|
58
|
+
hasCapability?: (resource: string, action: string) => boolean;
|
|
59
|
+
/** The user's message text for this turn. */
|
|
60
|
+
text: string;
|
|
61
|
+
requestId: string;
|
|
62
|
+
/** The turn's abort signal; only an awaited (`on`) evaluation is bound to it. */
|
|
63
|
+
signal?: AbortSignal;
|
|
64
|
+
/**
|
|
65
|
+
* The turn's tracer and trace metadata. The router runs before the graph,
|
|
66
|
+
* outside any LangChain run, so without these its Decision is not traced.
|
|
67
|
+
*/
|
|
68
|
+
trace?: DecisionTraceOptions;
|
|
69
|
+
/**
|
|
70
|
+
* Shadow mode only: receives what the router would have preloaded once its
|
|
71
|
+
* evaluation completes, so the host can compare it with what the turn
|
|
72
|
+
* actually loaded.
|
|
73
|
+
*/
|
|
74
|
+
onShadowVerdict?: (wouldPreload: readonly string[]) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type CapabilityRouter = (
|
|
78
|
+
turn: CapabilityRouteTurn,
|
|
79
|
+
) => Promise<ReadonlySet<string>>;
|
|
80
|
+
|
|
81
|
+
const NOTHING: ReadonlySet<string> = new Set<string>();
|
|
82
|
+
|
|
83
|
+
/** Narrows an env value to a router mode; anything else is `off`. */
|
|
84
|
+
export function capabilityRouterMode(value: unknown): CapabilityRouterMode {
|
|
85
|
+
return CAPABILITY_ROUTER_MODES.find((mode) => mode === value) ?? 'off';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The manifests the router may choose from: effectively on-demand plugins the
|
|
90
|
+
* thread has not loaded yet. `always` plugins are already bound and `silent`
|
|
91
|
+
* ones are never surfaced, so predicting them is pointless; a `hidden` one
|
|
92
|
+
* (no tool the user's delegation reaches), or one whose `requires` the
|
|
93
|
+
* delegation (`hasCapability`) does not grant, is not even named to the
|
|
94
|
+
* router.
|
|
95
|
+
*/
|
|
96
|
+
export function routableCandidates(
|
|
97
|
+
manifests: readonly RegisteredManifest[],
|
|
98
|
+
loaded: ReadonlySet<string>,
|
|
99
|
+
hidden: ReadonlySet<string> = NOTHING,
|
|
100
|
+
hasCapability?: (resource: string, action: string) => boolean,
|
|
101
|
+
): RoutableCapability[] {
|
|
102
|
+
return toRoutableCapabilities(
|
|
103
|
+
manifests
|
|
104
|
+
.filter(
|
|
105
|
+
({ pluginName, manifest }) =>
|
|
106
|
+
(manifest.visibility ?? 'on-demand') === 'on-demand' &&
|
|
107
|
+
!loaded.has(pluginName) &&
|
|
108
|
+
!hidden.has(pluginName) &&
|
|
109
|
+
(hasCapability === undefined ||
|
|
110
|
+
unmetRequirements(manifest, hasCapability).length === 0),
|
|
111
|
+
)
|
|
112
|
+
.map(({ pluginName, manifest }) => ({
|
|
113
|
+
name: pluginName,
|
|
114
|
+
title: manifest.title,
|
|
115
|
+
summary: manifest.summary,
|
|
116
|
+
})),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* After-turn comparison for shadow mode: which plugins the turn loaded on its
|
|
122
|
+
* own, and whether that matches what the router would have preloaded.
|
|
123
|
+
*/
|
|
124
|
+
export function shadowAgreement(args: {
|
|
125
|
+
priorLoaded: ReadonlySet<string>;
|
|
126
|
+
loadedAfter: ReadonlySet<string>;
|
|
127
|
+
wouldPreload: readonly string[];
|
|
128
|
+
}): { loadedDuringTurn: string[]; agree: boolean } {
|
|
129
|
+
const loadedDuringTurn = Array.from(args.loadedAfter).filter(
|
|
130
|
+
(name) => !args.priorLoaded.has(name),
|
|
131
|
+
);
|
|
132
|
+
const predicted = new Set(args.wouldPreload);
|
|
133
|
+
const agree =
|
|
134
|
+
predicted.size === loadedDuringTurn.length &&
|
|
135
|
+
loadedDuringTurn.every((name) => predicted.has(name));
|
|
136
|
+
return { loadedDuringTurn, agree };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function errorName(error: unknown): string {
|
|
140
|
+
return error instanceof Error ? error.name : typeof error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function list(names: readonly string[]): string {
|
|
144
|
+
return `[${names.join(', ')}]`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function createCapabilityRouter(
|
|
148
|
+
options: CapabilityRouterOptions,
|
|
149
|
+
): CapabilityRouter {
|
|
150
|
+
const { evaluator, logger } = options;
|
|
151
|
+
const now = options.now ?? (() => Date.now());
|
|
152
|
+
const background =
|
|
153
|
+
options.background ??
|
|
154
|
+
((work: Promise<unknown>) => {
|
|
155
|
+
void work;
|
|
156
|
+
});
|
|
157
|
+
// A missing Decision provider is a deployment fact, not a per-turn event:
|
|
158
|
+
// said once per object, then the router stays quiet.
|
|
159
|
+
let unavailableWarned = false;
|
|
160
|
+
|
|
161
|
+
// An ambiguous multi-provider configuration is a subclass: equally a
|
|
162
|
+
// deployment fact, but fixed by routing rather than by adding a provider.
|
|
163
|
+
const unavailable = (
|
|
164
|
+
error: unknown,
|
|
165
|
+
): error is DecisionProviderUnavailableError =>
|
|
166
|
+
error instanceof DecisionProviderUnavailableError;
|
|
167
|
+
|
|
168
|
+
const warnUnavailable = (
|
|
169
|
+
requestId: string,
|
|
170
|
+
mode: CapabilityRouterMode,
|
|
171
|
+
error: DecisionProviderUnavailableError,
|
|
172
|
+
) => {
|
|
173
|
+
if (unavailableWarned) return;
|
|
174
|
+
unavailableWarned = true;
|
|
175
|
+
const hint =
|
|
176
|
+
error instanceof AmbiguousDecisionProviderError
|
|
177
|
+
? `route ${CAPABILITY_ROUTE_DECISION_NAME} or set decisionProviderPolicy.defaultProviderId`
|
|
178
|
+
: 'set DECISION_PROVIDER or CAPABILITY_ROUTER=off';
|
|
179
|
+
logger.warn(
|
|
180
|
+
`[capability-router] request=${requestId} mode=${mode} status=fallback reason=${error.name} (${hint})`,
|
|
181
|
+
);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return async (turn) => {
|
|
185
|
+
if (turn.mode === 'off') return NOTHING;
|
|
186
|
+
const text = turn.text.trim();
|
|
187
|
+
if (!text) return NOTHING;
|
|
188
|
+
let hidden: ReadonlySet<string> | undefined;
|
|
189
|
+
try {
|
|
190
|
+
hidden =
|
|
191
|
+
typeof turn.hidden === 'function' ? await turn.hidden() : turn.hidden;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// Fail open like any other router failure: no preload this turn.
|
|
194
|
+
logger.warn(
|
|
195
|
+
`[capability-router] request=${turn.requestId} mode=${turn.mode} status=fallback reason=${errorName(error)} (hidden plugins unavailable)`,
|
|
196
|
+
);
|
|
197
|
+
return NOTHING;
|
|
198
|
+
}
|
|
199
|
+
const capabilities = routableCandidates(
|
|
200
|
+
turn.manifests,
|
|
201
|
+
turn.loaded,
|
|
202
|
+
hidden,
|
|
203
|
+
turn.hasCapability,
|
|
204
|
+
);
|
|
205
|
+
if (capabilities.length === 0) return NOTHING;
|
|
206
|
+
|
|
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
|
+
const { requestId } = turn;
|
|
211
|
+
|
|
212
|
+
if (turn.mode === 'shadow') {
|
|
213
|
+
const started = now();
|
|
214
|
+
background(
|
|
215
|
+
evaluator
|
|
216
|
+
.evaluate(capabilityRouteDecision, input, turn.trace)
|
|
217
|
+
.then((evaluation) => {
|
|
218
|
+
const verdict = decideCapabilityRoute(evaluation, capabilities);
|
|
219
|
+
logger.log(
|
|
220
|
+
`[capability-router-shadow] request=${requestId} status=ok ` +
|
|
221
|
+
`needsCapability=${verdict.needsCapability.toFixed(2)} ` +
|
|
222
|
+
`capability=${verdict.capability ?? '-'} ` +
|
|
223
|
+
`capabilityConfidence=${verdict.capabilityConfidence?.toFixed(2) ?? '-'} ` +
|
|
224
|
+
`wouldPreload=${list(verdict.preload)} reason=${verdict.reason} ` +
|
|
225
|
+
`latencyMs=${now() - started} provider=${evaluation.provider} model=${evaluation.model}`,
|
|
226
|
+
);
|
|
227
|
+
turn.onShadowVerdict?.(verdict.preload);
|
|
228
|
+
})
|
|
229
|
+
.catch((error: unknown) => {
|
|
230
|
+
if (unavailable(error)) {
|
|
231
|
+
warnUnavailable(requestId, 'shadow', error);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
logger.log(
|
|
235
|
+
`[capability-router-shadow] request=${requestId} status=failed errorType=${errorName(error)} latencyMs=${now() - started}`,
|
|
236
|
+
);
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
return NOTHING;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const evaluation = await evaluator.evaluate(
|
|
244
|
+
capabilityRouteDecision,
|
|
245
|
+
input,
|
|
246
|
+
{
|
|
247
|
+
...turn.trace,
|
|
248
|
+
...(turn.signal && { signal: turn.signal }),
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
const verdict = decideCapabilityRoute(evaluation, capabilities);
|
|
252
|
+
if (verdict.preload.length === 0) {
|
|
253
|
+
logger.debug?.(
|
|
254
|
+
`[capability-router] request=${requestId} mode=on status=none reason=${verdict.reason} candidates=${capabilities.length}`,
|
|
255
|
+
);
|
|
256
|
+
return NOTHING;
|
|
257
|
+
}
|
|
258
|
+
logger.log(
|
|
259
|
+
`[capability-router] request=${requestId} preloaded=${list(verdict.preload)} candidates=${capabilities.length}`,
|
|
260
|
+
);
|
|
261
|
+
return new Set(verdict.preload);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (unavailable(error)) {
|
|
264
|
+
warnUnavailable(requestId, 'on', error);
|
|
265
|
+
} else {
|
|
266
|
+
logger.warn(
|
|
267
|
+
`[capability-router] request=${requestId} mode=on status=fallback reason=${errorName(error)}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return NOTHING;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
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 half the window; Hermes fires at 50%)
|
|
6
|
+
* (default half the window; Hermes fires at 50%),
|
|
7
|
+
* never above what the summarizer may read;
|
|
7
8
|
* - `pruneAtTokens` — above this, old tool results are demoted to
|
|
8
9
|
* one-liners in the request (no model call, no
|
|
9
10
|
* state change) before the model sees them;
|
|
@@ -64,8 +65,14 @@ export interface ContextBudget {
|
|
|
64
65
|
resultCapChars: number;
|
|
65
66
|
requestCapTokens: number;
|
|
66
67
|
outputReserveTokens: number;
|
|
67
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* The summarizer's own input limit (what it may read to write the summary):
|
|
70
|
+
* the smaller of what the main model's request cap and the summarizing
|
|
71
|
+
* model's own request cap leave, less a margin for the summary prompt.
|
|
72
|
+
*/
|
|
68
73
|
summaryInputTokens: number;
|
|
74
|
+
/** The summarizing model's window, when the host resolved it. */
|
|
75
|
+
summaryWindowTokens?: number;
|
|
69
76
|
summarizeTriggerMessages?: number;
|
|
70
77
|
keepMessages: number;
|
|
71
78
|
}
|
|
@@ -142,12 +149,14 @@ export function contextKnobs(
|
|
|
142
149
|
};
|
|
143
150
|
}
|
|
144
151
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
/** Tokens the summary prompt and its framing need on top of the history it reads. */
|
|
153
|
+
const SUMMARY_PROMPT_MARGIN_TOKENS = 4_000;
|
|
154
|
+
|
|
155
|
+
/** What a request to a model with `window` may hold, the reply reserve left out. */
|
|
156
|
+
function requestCapFor(
|
|
157
|
+
window: number,
|
|
158
|
+
knobs: ContextKnobs,
|
|
159
|
+
): { requestCapTokens: number; outputReserveTokens: number } {
|
|
151
160
|
// The reply reserve can never eat a small window: cap it at a quarter.
|
|
152
161
|
const outputReserveTokens = Math.min(
|
|
153
162
|
knobs.outputReserveTokens,
|
|
@@ -157,9 +166,42 @@ export function contextBudgetFor(
|
|
|
157
166
|
1_000,
|
|
158
167
|
Math.floor(window * knobs.requestFraction) - outputReserveTokens,
|
|
159
168
|
);
|
|
169
|
+
return { requestCapTokens, outputReserveTokens };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The budget for a turn on `resolution.model`. `summarizer` is the window of
|
|
174
|
+
* the model that writes the summary (the `routing` role), resolved the same
|
|
175
|
+
* way: the history it is handed is bounded by its own window too, not only
|
|
176
|
+
* by the main model's. Omitted, the main model's window alone bounds it.
|
|
177
|
+
*/
|
|
178
|
+
export function contextBudgetFor(
|
|
179
|
+
resolution: ContextWindowResolution,
|
|
180
|
+
knobs: ContextKnobs = DEFAULT_CONTEXT_KNOBS,
|
|
181
|
+
summarizer?: ContextWindowResolution,
|
|
182
|
+
): ContextBudget {
|
|
183
|
+
const window = resolution.tokens;
|
|
184
|
+
const { requestCapTokens, outputReserveTokens } = requestCapFor(
|
|
185
|
+
window,
|
|
186
|
+
knobs,
|
|
187
|
+
);
|
|
188
|
+
const summaryRequestCap = summarizer
|
|
189
|
+
? Math.min(
|
|
190
|
+
requestCapTokens,
|
|
191
|
+
requestCapFor(summarizer.tokens, knobs).requestCapTokens,
|
|
192
|
+
)
|
|
193
|
+
: requestCapTokens;
|
|
194
|
+
const summaryInputTokens = Math.max(
|
|
195
|
+
1_000,
|
|
196
|
+
summaryRequestCap - SUMMARY_PROMPT_MARGIN_TOKENS,
|
|
197
|
+
);
|
|
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.
|
|
160
201
|
const summarizeAtTokens = Math.min(
|
|
161
202
|
Math.floor(window * knobs.summarizeFraction),
|
|
162
203
|
requestCapTokens,
|
|
204
|
+
summaryInputTokens,
|
|
163
205
|
);
|
|
164
206
|
const pruneAtTokens = Math.min(
|
|
165
207
|
Math.floor(window * knobs.pruneFraction),
|
|
@@ -177,7 +219,8 @@ export function contextBudgetFor(
|
|
|
177
219
|
),
|
|
178
220
|
requestCapTokens,
|
|
179
221
|
outputReserveTokens,
|
|
180
|
-
summaryInputTokens
|
|
222
|
+
summaryInputTokens,
|
|
223
|
+
...(summarizer ? { summaryWindowTokens: summarizer.tokens } : {}),
|
|
181
224
|
...(knobs.summarizeTriggerMessages !== undefined
|
|
182
225
|
? { summarizeTriggerMessages: knobs.summarizeTriggerMessages }
|
|
183
226
|
: {}),
|
|
@@ -196,6 +239,50 @@ export function estimateContentTokens(content: unknown): number {
|
|
|
196
239
|
}
|
|
197
240
|
}
|
|
198
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Per-message memo of the chars/4 estimates: every model step of a turn
|
|
244
|
+
* re-estimates the whole history (the context guard, the turn budget, the
|
|
245
|
+
* summarizer's trigger), and the history is the same message objects from
|
|
246
|
+
* step to step. An entry is reused only while the message still holds the
|
|
247
|
+
* same `content` / `tool_calls` values it was computed from.
|
|
248
|
+
*/
|
|
249
|
+
interface MessageEstimate {
|
|
250
|
+
content: unknown;
|
|
251
|
+
contentTokens: number;
|
|
252
|
+
toolCalls?: { value: unknown; tokens: number };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const messageEstimates = new WeakMap<object, MessageEstimate>();
|
|
256
|
+
|
|
257
|
+
function estimateOf(message: { content: unknown }): MessageEstimate {
|
|
258
|
+
const cached = messageEstimates.get(message);
|
|
259
|
+
if (cached && cached.content === message.content) return cached;
|
|
260
|
+
const fresh: MessageEstimate = {
|
|
261
|
+
content: message.content,
|
|
262
|
+
contentTokens: estimateContentTokens(message.content),
|
|
263
|
+
};
|
|
264
|
+
messageEstimates.set(message, fresh);
|
|
265
|
+
return fresh;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** `estimateContentTokens(message.content)`, memoised per message. */
|
|
269
|
+
export function messageContentTokens(message: { content: unknown }): number {
|
|
270
|
+
return estimateOf(message).contentTokens;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** `estimateContentTokens(toolCalls)` for `message`'s tool calls, memoised per message. */
|
|
274
|
+
export function messageToolCallTokens(
|
|
275
|
+
message: { content: unknown },
|
|
276
|
+
toolCalls: unknown,
|
|
277
|
+
): number {
|
|
278
|
+
const entry = estimateOf(message);
|
|
279
|
+
if (entry.toolCalls && entry.toolCalls.value === toolCalls)
|
|
280
|
+
return entry.toolCalls.tokens;
|
|
281
|
+
const tokens = estimateContentTokens(toolCalls);
|
|
282
|
+
entry.toolCalls = { value: toolCalls, tokens };
|
|
283
|
+
return tokens;
|
|
284
|
+
}
|
|
285
|
+
|
|
199
286
|
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}`;
|
|
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}${b.summaryWindowTokens !== undefined ? ` (summarizer window ${b.summaryWindowTokens})` : ''}`;
|
|
201
288
|
}
|