@ixo/oracle-runtime-workers 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +663 -0
- package/src/core/domain-context/transport.ts +382 -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 +111 -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/src/core/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { baseEnvSchema, composeEnvSchema, validateEnv } from './env';
|
|
|
29
29
|
import {
|
|
30
30
|
createLlmAdapter,
|
|
31
31
|
openRouterAttributionHeaders,
|
|
32
|
+
platformStreamLivenessEnv,
|
|
32
33
|
type OpenRouterLlmAdapter,
|
|
33
34
|
} from './llm';
|
|
34
35
|
import {
|
|
@@ -678,6 +679,7 @@ export function createRuntimeCore(opts: RuntimeCoreOptions): RuntimeCore {
|
|
|
678
679
|
| 'high'
|
|
679
680
|
| undefined,
|
|
680
681
|
ORACLE_NAME: String(validated.config.ORACLE_NAME),
|
|
682
|
+
...platformStreamLivenessEnv(validated.config),
|
|
681
683
|
},
|
|
682
684
|
logger,
|
|
683
685
|
);
|
package/src/core/llm.ts
CHANGED
|
@@ -3,6 +3,19 @@ import { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';
|
|
|
3
3
|
import { ChatOpenAI } from '@langchain/openai';
|
|
4
4
|
import { Client } from 'langsmith';
|
|
5
5
|
import type { ChatOpenAIFields, Logger, ModelRole } from '../plugin-api/types';
|
|
6
|
+
import {
|
|
7
|
+
livenessFetchFor,
|
|
8
|
+
livenessModelFields,
|
|
9
|
+
type StreamLivenessSettings,
|
|
10
|
+
} from '../llm/stream-liveness';
|
|
11
|
+
import {
|
|
12
|
+
LLM_BYO_STREAM_IDLE_TIMEOUT_MS_DEFAULT,
|
|
13
|
+
LLM_HEADERS_TIMEOUT_MS_DEFAULT,
|
|
14
|
+
LLM_STREAM_IDLE_TIMEOUT_MS_DEFAULT,
|
|
15
|
+
LLM_STREAM_RETRIES_DEFAULT,
|
|
16
|
+
LLM_STREAM_RETRIES_MAX,
|
|
17
|
+
TURN_TIMEOUT_MS_DEFAULT,
|
|
18
|
+
} from './env';
|
|
6
19
|
import { fetchOpenRouterPrices } from './openrouter-pricing';
|
|
7
20
|
import type { LlmAdapter } from './runtime-context';
|
|
8
21
|
import { NOOP_LOGGER } from './utils';
|
|
@@ -45,6 +58,13 @@ export interface ModelCatalogEntry {
|
|
|
45
58
|
blurb: string;
|
|
46
59
|
/** Whether the model accepts image input. */
|
|
47
60
|
vision: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* The model's context window in tokens, as the OpenRouter catalogue lists
|
|
63
|
+
* it (the smaller of `context_length` and `top_provider.context_length`).
|
|
64
|
+
* The built-in fallback when the live catalogue cannot be reached
|
|
65
|
+
* (`BUILTIN_CONTEXT_WINDOWS`, `context-window.ts`).
|
|
66
|
+
*/
|
|
67
|
+
contextTokens: number;
|
|
48
68
|
/**
|
|
49
69
|
* Baseline list price, used when live OpenRouter pricing is unavailable
|
|
50
70
|
* (see `openrouter-pricing.ts`). Raw provider price; markup applied later.
|
|
@@ -88,6 +108,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
|
|
|
88
108
|
tier: 'everyday',
|
|
89
109
|
blurb: 'Fast and low-cost — great for everyday questions and quick help.',
|
|
90
110
|
vision: true,
|
|
111
|
+
contextTokens: 400_000,
|
|
91
112
|
baselinePrice: { inputPerMillion: 0.2, outputPerMillion: 1.2 },
|
|
92
113
|
},
|
|
93
114
|
{
|
|
@@ -97,6 +118,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
|
|
|
97
118
|
tier: 'everyday',
|
|
98
119
|
blurb: 'Speedy and inexpensive, and it can read images too.',
|
|
99
120
|
vision: true,
|
|
121
|
+
contextTokens: 1_048_576,
|
|
100
122
|
baselinePrice: { inputPerMillion: 0.25, outputPerMillion: 1.5 },
|
|
101
123
|
},
|
|
102
124
|
{
|
|
@@ -106,6 +128,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
|
|
|
106
128
|
tier: 'everyday',
|
|
107
129
|
blurb: 'Budget-friendly open model that handles general chat well.',
|
|
108
130
|
vision: false,
|
|
131
|
+
contextTokens: 1_048_576,
|
|
109
132
|
baselinePrice: { inputPerMillion: 0.2968, outputPerMillion: 0.9328 },
|
|
110
133
|
},
|
|
111
134
|
{
|
|
@@ -115,6 +138,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
|
|
|
115
138
|
tier: 'everyday',
|
|
116
139
|
blurb: 'Low-cost open model that can also look at images.',
|
|
117
140
|
vision: true,
|
|
141
|
+
contextTokens: 262_144,
|
|
118
142
|
baselinePrice: { inputPerMillion: 0.57, outputPerMillion: 2.85 },
|
|
119
143
|
},
|
|
120
144
|
|
|
@@ -126,6 +150,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
|
|
|
126
150
|
tier: 'balanced',
|
|
127
151
|
blurb: 'A smart all-rounder that balances speed and reasoning.',
|
|
128
152
|
vision: true,
|
|
153
|
+
contextTokens: 1_050_000,
|
|
129
154
|
baselinePrice: { inputPerMillion: 1.0, outputPerMillion: 6.0 },
|
|
130
155
|
},
|
|
131
156
|
{
|
|
@@ -135,6 +160,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
|
|
|
135
160
|
tier: 'balanced',
|
|
136
161
|
blurb: 'Capable multimodal model for tougher, more detailed tasks.',
|
|
137
162
|
vision: true,
|
|
163
|
+
contextTokens: 1_048_576,
|
|
138
164
|
baselinePrice: { inputPerMillion: 1.5, outputPerMillion: 9.0 },
|
|
139
165
|
},
|
|
140
166
|
{
|
|
@@ -144,6 +170,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
|
|
|
144
170
|
tier: 'balanced',
|
|
145
171
|
blurb: 'Great at careful writing, reasoning and coding.',
|
|
146
172
|
vision: true,
|
|
173
|
+
contextTokens: 1_000_000,
|
|
147
174
|
baselinePrice: { inputPerMillion: 2.0, outputPerMillion: 10.0 },
|
|
148
175
|
},
|
|
149
176
|
|
|
@@ -155,6 +182,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
|
|
|
155
182
|
tier: 'top',
|
|
156
183
|
blurb: 'Powerful open model for complex, long-running tasks.',
|
|
157
184
|
vision: true,
|
|
185
|
+
contextTokens: 1_048_576,
|
|
158
186
|
baselinePrice: { inputPerMillion: 3.0, outputPerMillion: 15.0 },
|
|
159
187
|
},
|
|
160
188
|
{
|
|
@@ -164,6 +192,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
|
|
|
164
192
|
tier: 'top',
|
|
165
193
|
blurb: "Anthropic's most capable model for hard problems.",
|
|
166
194
|
vision: true,
|
|
195
|
+
contextTokens: 1_000_000,
|
|
167
196
|
baselinePrice: { inputPerMillion: 5.0, outputPerMillion: 25.0 },
|
|
168
197
|
},
|
|
169
198
|
{
|
|
@@ -173,6 +202,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [
|
|
|
173
202
|
tier: 'top',
|
|
174
203
|
blurb: "OpenAI's flagship for the most complex work.",
|
|
175
204
|
vision: true,
|
|
205
|
+
contextTokens: 1_050_000,
|
|
176
206
|
baselinePrice: { inputPerMillion: 5.0, outputPerMillion: 30.0 },
|
|
177
207
|
},
|
|
178
208
|
];
|
|
@@ -421,9 +451,18 @@ export async function listModels(
|
|
|
421
451
|
* Every role the provider maps to a model id. The plugin-API exposes a lean
|
|
422
452
|
* `ModelRole` (`'main' | 'subagent' | 'utility' | string`); the adapter falls
|
|
423
453
|
* back to `subagent` for unrecognized strings.
|
|
454
|
+
*
|
|
455
|
+
* `summarizer` condenses a thread's history (the summarization middleware).
|
|
456
|
+
* It is always served by the turn's own `main` model, on the same lane: the
|
|
457
|
+
* same id (`DEFAULT_MODEL`, a per-request choice passed as `params.model`,
|
|
458
|
+
* or the BYO model), so the summary is written by a model whose window holds
|
|
459
|
+
* everything the turn holds, and a BYO turn's history never leaves the
|
|
460
|
+
* user's own provider. `routing` stays the cheap helper (capability router,
|
|
461
|
+
* other one-shot helper calls).
|
|
424
462
|
*/
|
|
425
463
|
export type ProviderModelRole =
|
|
426
464
|
| 'main'
|
|
465
|
+
| 'summarizer'
|
|
427
466
|
| 'skills'
|
|
428
467
|
| 'subagent'
|
|
429
468
|
| 'vision'
|
|
@@ -434,9 +473,13 @@ export type ProviderModelRole =
|
|
|
434
473
|
| 'custom_medium'
|
|
435
474
|
| 'custom_low';
|
|
436
475
|
|
|
437
|
-
/**
|
|
476
|
+
/**
|
|
477
|
+
* OpenRouter role map. `main` and `summarizer` are resolved through
|
|
478
|
+
* `getDefaultModelId` (the entries here are the catalog default).
|
|
479
|
+
*/
|
|
438
480
|
export const OPENROUTER_MODEL_MAP: Record<ProviderModelRole, string> = {
|
|
439
481
|
main: DEFAULT_MODEL_ID,
|
|
482
|
+
summarizer: DEFAULT_MODEL_ID,
|
|
440
483
|
skills: 'openai/gpt-5.6-luna',
|
|
441
484
|
subagent: 'openai/gpt-5.6-luna',
|
|
442
485
|
vision: 'google/gemini-3.1-flash-lite',
|
|
@@ -481,6 +524,8 @@ export const NEBIUS_BASE_URL = 'https://api.tokenfactory.nebius.com/v1/';
|
|
|
481
524
|
*/
|
|
482
525
|
export const NEBIUS_MODEL_MAP: Record<ProviderModelRole, string> = {
|
|
483
526
|
main: 'Qwen/Qwen3-235B-A22B-Thinking-2507',
|
|
527
|
+
// The main model, as on every lane.
|
|
528
|
+
summarizer: 'Qwen/Qwen3-235B-A22B-Thinking-2507',
|
|
484
529
|
skills: 'Qwen/Qwen3-235B-A22B-Thinking-2507',
|
|
485
530
|
subagent: 'Qwen/Qwen3-235B-A22B-Instruct-2507',
|
|
486
531
|
vision: 'Qwen/Qwen2.5-VL-72B-Instruct',
|
|
@@ -492,6 +537,67 @@ export const NEBIUS_MODEL_MAP: Record<ProviderModelRole, string> = {
|
|
|
492
537
|
embedding: 'Qwen/Qwen3-Embedding-8B',
|
|
493
538
|
};
|
|
494
539
|
|
|
540
|
+
// ── Built-in context windows ────────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Context windows (tokens) of the non-catalog model ids the runtime itself
|
|
544
|
+
* sends to a provider, keyed the way `catalogCandidates` (`context-window.ts`)
|
|
545
|
+
* looks a model up: an OpenRouter or Nebius id as is, a BYO provider-native
|
|
546
|
+
* id under its vendor prefix (`claude-haiku-4-5` → `anthropic/claude-haiku-4-5`).
|
|
547
|
+
*
|
|
548
|
+
* Unless noted, a number is OpenRouter's `/models` listing (the smaller of
|
|
549
|
+
* `context_length` and `top_provider.context_length`) as of 2026-10-09.
|
|
550
|
+
* Embedding models are left out: their input limit is not a chat window and
|
|
551
|
+
* no context budget is derived for them.
|
|
552
|
+
*/
|
|
553
|
+
const NON_CATALOG_CONTEXT_WINDOWS: Readonly<Record<string, number>> = {
|
|
554
|
+
// OpenRouter role map and `main` fallbacks.
|
|
555
|
+
'openai/gpt-oss-120b': 131_072,
|
|
556
|
+
'meta-llama/llama-3.1-8b-instruct': 131_072,
|
|
557
|
+
'qwen/qwen3-235b-a22b-thinking-2507': 131_072,
|
|
558
|
+
'google/gemini-2.5-flash-lite': 1_048_576,
|
|
559
|
+
// BYO provider-native ids (`BYO_ROLE_MODELS`, `BYO_PROVIDER_MODELS` in
|
|
560
|
+
// `src/llm/byo-catalog.ts`) the curated catalog does not list. The
|
|
561
|
+
// ChatGPT lane resolves to the same `openai/…` keys as the OpenAI lane.
|
|
562
|
+
'openai/gpt-5.6-terra': 1_050_000,
|
|
563
|
+
'anthropic/claude-opus-5': 1_000_000,
|
|
564
|
+
// Anthropic's native id; OpenRouter lists it as `anthropic/claude-haiku-4.5`.
|
|
565
|
+
'anthropic/claude-haiku-4-5': 200_000,
|
|
566
|
+
'google/gemini-3.6-flash': 1_048_576,
|
|
567
|
+
// Not on OpenRouter under this id: Google's Gemini API model page
|
|
568
|
+
// (`gemini-3.1-pro-preview`, input token limit 1,048,576).
|
|
569
|
+
'google/gemini-3.1-pro': 1_048_576,
|
|
570
|
+
'deepseek/deepseek-v4-flash': 1_048_576,
|
|
571
|
+
'deepseek/deepseek-v4-pro': 1_024_000,
|
|
572
|
+
// Nebius role map. Nebius's own listing needs an API key; these are the
|
|
573
|
+
// model publishers' published windows (the Hugging Face model configs'
|
|
574
|
+
// `max_position_embeddings`; Meta's Llama 3.1 models, whose configs are
|
|
575
|
+
// gated, at Meta's published 128K, the 131,072 OpenRouter lists for
|
|
576
|
+
// Llama 3.1 8B). A deployment serving less is learned from its first
|
|
577
|
+
// overflow error.
|
|
578
|
+
'Qwen/Qwen3-235B-A22B-Thinking-2507': 262_144,
|
|
579
|
+
'Qwen/Qwen3-235B-A22B-Instruct-2507': 262_144,
|
|
580
|
+
'Qwen/Qwen3-30B-A3B-Instruct-2507': 262_144,
|
|
581
|
+
'Qwen/Qwen2.5-VL-72B-Instruct': 128_000,
|
|
582
|
+
'meta-llama/Llama-Guard-3-8B': 131_072,
|
|
583
|
+
'meta-llama/Meta-Llama-3.1-8B-Instruct': 131_072,
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Every model id the runtime knows a context window for without asking
|
|
588
|
+
* anyone: the curated catalog's `contextTokens` plus
|
|
589
|
+
* {@link NON_CATALOG_CONTEXT_WINDOWS}. The context-window resolver falls
|
|
590
|
+
* back to it when the live OpenRouter catalogue is unreachable or does not
|
|
591
|
+
* list the id, before the deployment default.
|
|
592
|
+
*/
|
|
593
|
+
export const BUILTIN_CONTEXT_WINDOWS: ReadonlyMap<string, number> = new Map([
|
|
594
|
+
...MODEL_CATALOG.map((entry): [string, number] => [
|
|
595
|
+
entry.id,
|
|
596
|
+
entry.contextTokens,
|
|
597
|
+
]),
|
|
598
|
+
...Object.entries(NON_CATALOG_CONTEXT_WINDOWS),
|
|
599
|
+
]);
|
|
600
|
+
|
|
495
601
|
/** Provider families the platform adapter can be built for. */
|
|
496
602
|
export type LlmProvider = 'openrouter' | 'nebius';
|
|
497
603
|
|
|
@@ -505,6 +611,73 @@ export interface LlmEnv {
|
|
|
505
611
|
LLM_PROVIDER?: LlmProvider;
|
|
506
612
|
/** Required when `LLM_PROVIDER=nebius`. */
|
|
507
613
|
NEBIUS_API_KEY?: string;
|
|
614
|
+
/** Model-call liveness budgets (defaults in `env.ts`). */
|
|
615
|
+
LLM_HEADERS_TIMEOUT_MS?: number;
|
|
616
|
+
LLM_STREAM_IDLE_TIMEOUT_MS?: number;
|
|
617
|
+
LLM_STREAM_RETRIES?: number;
|
|
618
|
+
/** The turn deadline the stall retries are clamped to. */
|
|
619
|
+
TURN_TIMEOUT_MS?: number;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** The liveness-related env keys, as validated config (numbers) or raw Worker env (strings). */
|
|
623
|
+
export interface StreamLivenessEnv {
|
|
624
|
+
LLM_HEADERS_TIMEOUT_MS?: unknown;
|
|
625
|
+
LLM_STREAM_IDLE_TIMEOUT_MS?: unknown;
|
|
626
|
+
LLM_BYO_STREAM_IDLE_TIMEOUT_MS?: unknown;
|
|
627
|
+
LLM_STREAM_RETRIES?: unknown;
|
|
628
|
+
TURN_TIMEOUT_MS?: unknown;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** A whole number at least `min` (a number or a numeric string), else undefined. */
|
|
632
|
+
function wholeNumber(value: unknown, min: number): number | undefined {
|
|
633
|
+
const n =
|
|
634
|
+
typeof value === 'number'
|
|
635
|
+
? value
|
|
636
|
+
: typeof value === 'string' && value.trim() !== ''
|
|
637
|
+
? Number(value)
|
|
638
|
+
: Number.NaN;
|
|
639
|
+
return Number.isInteger(n) && n >= min ? n : undefined;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The liveness budgets of a model lane: `platform` (OpenRouter / Nebius,
|
|
644
|
+
* idle `LLM_STREAM_IDLE_TIMEOUT_MS`) or `byo` (the user's own provider, idle
|
|
645
|
+
* `LLM_BYO_STREAM_IDLE_TIMEOUT_MS`). Unset or invalid values take the
|
|
646
|
+
* defaults in `env.ts`.
|
|
647
|
+
*
|
|
648
|
+
* Retries are clamped so every attempt the guard may make fits the turn:
|
|
649
|
+
* the largest count up to `LLM_STREAM_RETRIES` (at most 5) with
|
|
650
|
+
* `(retries + 1) × (headers + idle) ≤ TURN_TIMEOUT_MS`, never below 0. A
|
|
651
|
+
* retry that cannot finish before the deadline would only end in
|
|
652
|
+
* `budget_exhausted` after a wasted second request. With the defaults the
|
|
653
|
+
* platform lane keeps one retry (2 × 210 s) and BYO lanes get none (420 s).
|
|
654
|
+
*/
|
|
655
|
+
export function streamLivenessFromEnv(
|
|
656
|
+
env: StreamLivenessEnv,
|
|
657
|
+
lane: 'platform' | 'byo',
|
|
658
|
+
): StreamLivenessSettings {
|
|
659
|
+
const headersTimeoutMs =
|
|
660
|
+
wholeNumber(env.LLM_HEADERS_TIMEOUT_MS, 1) ??
|
|
661
|
+
LLM_HEADERS_TIMEOUT_MS_DEFAULT;
|
|
662
|
+
const idleTimeoutMs =
|
|
663
|
+
lane === 'platform'
|
|
664
|
+
? (wholeNumber(env.LLM_STREAM_IDLE_TIMEOUT_MS, 1) ??
|
|
665
|
+
LLM_STREAM_IDLE_TIMEOUT_MS_DEFAULT)
|
|
666
|
+
: (wholeNumber(env.LLM_BYO_STREAM_IDLE_TIMEOUT_MS, 1) ??
|
|
667
|
+
LLM_BYO_STREAM_IDLE_TIMEOUT_MS_DEFAULT);
|
|
668
|
+
const configured = Math.min(
|
|
669
|
+
wholeNumber(env.LLM_STREAM_RETRIES, 0) ?? LLM_STREAM_RETRIES_DEFAULT,
|
|
670
|
+
LLM_STREAM_RETRIES_MAX,
|
|
671
|
+
);
|
|
672
|
+
const turnTimeoutMs =
|
|
673
|
+
wholeNumber(env.TURN_TIMEOUT_MS, 1) ?? TURN_TIMEOUT_MS_DEFAULT;
|
|
674
|
+
const fitting =
|
|
675
|
+
Math.floor(turnTimeoutMs / (headersTimeoutMs + idleTimeoutMs)) - 1;
|
|
676
|
+
return {
|
|
677
|
+
headersTimeoutMs,
|
|
678
|
+
idleTimeoutMs,
|
|
679
|
+
retries: Math.max(0, Math.min(configured, fitting)),
|
|
680
|
+
};
|
|
508
681
|
}
|
|
509
682
|
|
|
510
683
|
/**
|
|
@@ -529,6 +702,29 @@ export function llmEnvFromWorkerEnv(env: Record<string, unknown>): LlmEnv {
|
|
|
529
702
|
ORACLE_NAME: str('ORACLE_NAME'),
|
|
530
703
|
LLM_PROVIDER: str('LLM_PROVIDER') === 'nebius' ? 'nebius' : 'openrouter',
|
|
531
704
|
NEBIUS_API_KEY: str('NEBIUS_API_KEY'),
|
|
705
|
+
...platformStreamLivenessEnv(env),
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** The platform lane's liveness keys of `env`, as numbers (unset when absent or invalid). */
|
|
710
|
+
export function platformStreamLivenessEnv(
|
|
711
|
+
env: StreamLivenessEnv,
|
|
712
|
+
): Pick<
|
|
713
|
+
LlmEnv,
|
|
714
|
+
| 'LLM_HEADERS_TIMEOUT_MS'
|
|
715
|
+
| 'LLM_STREAM_IDLE_TIMEOUT_MS'
|
|
716
|
+
| 'LLM_STREAM_RETRIES'
|
|
717
|
+
| 'TURN_TIMEOUT_MS'
|
|
718
|
+
> {
|
|
719
|
+
const headers = wholeNumber(env.LLM_HEADERS_TIMEOUT_MS, 1);
|
|
720
|
+
const idle = wholeNumber(env.LLM_STREAM_IDLE_TIMEOUT_MS, 1);
|
|
721
|
+
const retries = wholeNumber(env.LLM_STREAM_RETRIES, 0);
|
|
722
|
+
const turnTimeout = wholeNumber(env.TURN_TIMEOUT_MS, 1);
|
|
723
|
+
return {
|
|
724
|
+
...(headers !== undefined ? { LLM_HEADERS_TIMEOUT_MS: headers } : {}),
|
|
725
|
+
...(idle !== undefined ? { LLM_STREAM_IDLE_TIMEOUT_MS: idle } : {}),
|
|
726
|
+
...(retries !== undefined ? { LLM_STREAM_RETRIES: retries } : {}),
|
|
727
|
+
...(turnTimeout !== undefined ? { TURN_TIMEOUT_MS: turnTimeout } : {}),
|
|
532
728
|
};
|
|
533
729
|
}
|
|
534
730
|
|
|
@@ -560,12 +756,17 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|
|
560
756
|
* Build the platform LLM adapter from validated config, selecting the
|
|
561
757
|
* provider by `LLM_PROVIDER` (`openrouter` default | `nebius`). `ChatOpenAI`
|
|
562
758
|
* speaks to both OpenAI-compatible endpoints over `fetch`, so it runs
|
|
563
|
-
* unchanged on Workers. Every model is created with `maxRetries: 2
|
|
759
|
+
* unchanged on Workers. Every model is created with `maxRetries: 2` and the
|
|
760
|
+
* stream liveness guard (`src/llm/stream-liveness.ts`) as its `fetch`, with
|
|
761
|
+
* the platform lane's budgets (`LLM_HEADERS_TIMEOUT_MS`,
|
|
762
|
+
* `LLM_STREAM_IDLE_TIMEOUT_MS`, `LLM_STREAM_RETRIES`).
|
|
564
763
|
*
|
|
565
764
|
* OpenRouter role handling mirrors the Node provider:
|
|
566
765
|
* - `main` → `DEFAULT_MODEL` (or the catalog default), with the OpenRouter
|
|
567
766
|
* `models` fallback array and `provider.sort: 'latency'`; reasoning
|
|
568
767
|
* effort from `MAIN_REASONING_EFFORT`.
|
|
768
|
+
* - `summarizer` → the same model as `main` (no fallback array), with the
|
|
769
|
+
* helper calls' `medium` reasoning effort.
|
|
569
770
|
* - every other role → the fixed map; unknown roles fall back to `subagent`.
|
|
570
771
|
* - `params.model` always wins over the role default.
|
|
571
772
|
*
|
|
@@ -584,11 +785,12 @@ export function createLlmAdapter(
|
|
|
584
785
|
): OpenRouterLlmAdapter {
|
|
585
786
|
if (env.LLM_PROVIDER === 'nebius') return createNebiusAdapter(env, logger);
|
|
586
787
|
const defaultModelId = getDefaultModelId(env);
|
|
788
|
+
const liveness = streamLivenessFromEnv(env, 'platform');
|
|
587
789
|
const mainEffort = env.MAIN_REASONING_EFFORT ?? 'medium';
|
|
588
790
|
const headers = openRouterAttributionHeaders(env.ORACLE_NAME);
|
|
589
791
|
|
|
590
792
|
const modelForRole = (role: ModelRole): string => {
|
|
591
|
-
if (role === 'main') return defaultModelId;
|
|
793
|
+
if (role === 'main' || role === 'summarizer') return defaultModelId;
|
|
592
794
|
return (
|
|
593
795
|
OPENROUTER_MODEL_MAP[role as ProviderModelRole] ??
|
|
594
796
|
OPENROUTER_MODEL_MAP.subagent
|
|
@@ -616,6 +818,9 @@ export function createLlmAdapter(
|
|
|
616
818
|
}
|
|
617
819
|
: {};
|
|
618
820
|
|
|
821
|
+
// `MAIN_REASONING_EFFORT` is for the reply only; helper calls (the
|
|
822
|
+
// summarizer included, although it runs on the main model) reason at
|
|
823
|
+
// `medium`.
|
|
619
824
|
const reasoningKwargs = {
|
|
620
825
|
effort: role === 'main' ? mainEffort : 'medium',
|
|
621
826
|
summary: 'auto',
|
|
@@ -636,6 +841,7 @@ export function createLlmAdapter(
|
|
|
636
841
|
return new ChatOpenAI({
|
|
637
842
|
temperature: 0.8,
|
|
638
843
|
maxRetries: 2,
|
|
844
|
+
...livenessModelFields(liveness),
|
|
639
845
|
apiKey: env.OPEN_ROUTER_API_KEY,
|
|
640
846
|
...rest,
|
|
641
847
|
model,
|
|
@@ -643,6 +849,12 @@ export function createLlmAdapter(
|
|
|
643
849
|
configuration: {
|
|
644
850
|
baseURL: OPENROUTER_BASE_URL,
|
|
645
851
|
...configuration,
|
|
852
|
+
fetch: livenessFetchFor({
|
|
853
|
+
settings: liveness,
|
|
854
|
+
label: `openrouter ${String(role)} ${model}`,
|
|
855
|
+
logger,
|
|
856
|
+
callerFetch: configuration.fetch,
|
|
857
|
+
}),
|
|
646
858
|
defaultHeaders: { ...headers, ...overrideHeaders },
|
|
647
859
|
},
|
|
648
860
|
modelKwargs: {
|
|
@@ -687,6 +899,7 @@ function createNebiusAdapter(
|
|
|
687
899
|
|
|
688
900
|
const modelForRole = (role: ModelRole): string =>
|
|
689
901
|
NEBIUS_MODEL_MAP[role as ProviderModelRole] ?? NEBIUS_MODEL_MAP.subagent;
|
|
902
|
+
const liveness = streamLivenessFromEnv(env, 'platform');
|
|
690
903
|
|
|
691
904
|
const get = (role: ModelRole, params?: ChatOpenAIFields): BaseChatModel => {
|
|
692
905
|
const {
|
|
@@ -701,17 +914,25 @@ function createNebiusAdapter(
|
|
|
701
914
|
logger.debug?.(
|
|
702
915
|
`[llm] creating model — provider=nebius, role=${String(role)}, model=${model}`,
|
|
703
916
|
);
|
|
917
|
+
const configuration = asRecord(paramsConfiguration);
|
|
704
918
|
return new ChatOpenAI({
|
|
705
919
|
// Cold for classification (guard), platform default otherwise.
|
|
706
920
|
temperature: role === 'guard' ? 0 : 0.8,
|
|
707
921
|
maxRetries: 2,
|
|
922
|
+
...livenessModelFields(liveness),
|
|
708
923
|
apiKey,
|
|
709
924
|
__includeRawResponse: true,
|
|
710
925
|
...rest,
|
|
711
926
|
model,
|
|
712
927
|
configuration: {
|
|
713
928
|
baseURL: NEBIUS_BASE_URL,
|
|
714
|
-
...
|
|
929
|
+
...configuration,
|
|
930
|
+
fetch: livenessFetchFor({
|
|
931
|
+
settings: liveness,
|
|
932
|
+
label: `nebius ${String(role)} ${model}`,
|
|
933
|
+
logger,
|
|
934
|
+
callerFetch: configuration.fetch,
|
|
935
|
+
}),
|
|
715
936
|
},
|
|
716
937
|
});
|
|
717
938
|
};
|
|
@@ -17,6 +17,12 @@ import type {
|
|
|
17
17
|
PluginTool,
|
|
18
18
|
} from '../plugin-api/types';
|
|
19
19
|
import type { DeliveryProfile } from '../delivery/types';
|
|
20
|
+
import type {
|
|
21
|
+
DomainContextOptions,
|
|
22
|
+
DomainContextPins,
|
|
23
|
+
DomainContextProvenance,
|
|
24
|
+
DomainContextResolver,
|
|
25
|
+
} from './domain-context';
|
|
20
26
|
import type { Registries } from './registries';
|
|
21
27
|
import type { AmbientServices, RunConfigContext } from './runtime-context';
|
|
22
28
|
import type { TMainAgentGraphState } from './state';
|
|
@@ -180,6 +186,20 @@ export interface MainAgentArgs {
|
|
|
180
186
|
*/
|
|
181
187
|
delivery?: DeliveryProfile;
|
|
182
188
|
hooks?: MainAgentHooks;
|
|
189
|
+
/**
|
|
190
|
+
* Observe-only domain context. Loaded only when `mode` is `observe` and a
|
|
191
|
+
* resolver is given; absent or `off` leaves the build unchanged.
|
|
192
|
+
*/
|
|
193
|
+
domainContext?: DomainContextOptions;
|
|
194
|
+
/** The anchor cache, one per user object and shared by its sessions. */
|
|
195
|
+
domainResolver?: DomainContextResolver;
|
|
196
|
+
/** Anchors a resumed durable run started with: the same revisions again. */
|
|
197
|
+
domainPins?: DomainContextPins;
|
|
198
|
+
/**
|
|
199
|
+
* Called with the turn's updated provenance when `read_domain_document`
|
|
200
|
+
* reads a document the turn had not read yet.
|
|
201
|
+
*/
|
|
202
|
+
onDomainProvenance?: (provenance: DomainContextProvenance[]) => void;
|
|
183
203
|
}
|
|
184
204
|
|
|
185
205
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -206,4 +226,12 @@ export interface MainAgentBuildResult {
|
|
|
206
226
|
* middlewares and sub-agents see the same channel the Node runtime provides.
|
|
207
227
|
*/
|
|
208
228
|
context: RunConfigContext;
|
|
229
|
+
/**
|
|
230
|
+
* What the turn was given as domain context (present when it loaded):
|
|
231
|
+
* the provenance to emit and record, and the anchors to pin on the run.
|
|
232
|
+
*/
|
|
233
|
+
domainContext?: {
|
|
234
|
+
provenance: DomainContextProvenance[];
|
|
235
|
+
pins: DomainContextPins;
|
|
236
|
+
};
|
|
209
237
|
}
|
package/src/core/main-agent.ts
CHANGED
|
@@ -81,6 +81,7 @@ import { CREATE_ARTIFACT_TOOL } from '../artifacts/tool';
|
|
|
81
81
|
import type { SessionSurface } from '../plugin-api/types';
|
|
82
82
|
|
|
83
83
|
import { taskExecutionProfile } from './execution-profile';
|
|
84
|
+
import { prepareDomainContext } from './domain-context';
|
|
84
85
|
|
|
85
86
|
const PLUGIN_LOGGER_COMPONENT = 'main-agent';
|
|
86
87
|
|
|
@@ -149,6 +150,10 @@ export async function createMainAgent(
|
|
|
149
150
|
contextBudget,
|
|
150
151
|
delivery,
|
|
151
152
|
hooks,
|
|
153
|
+
domainContext,
|
|
154
|
+
domainResolver,
|
|
155
|
+
domainPins,
|
|
156
|
+
onDomainProvenance,
|
|
152
157
|
} = args;
|
|
153
158
|
const surface: SessionSurface | undefined =
|
|
154
159
|
delivery?.kind === 'chat'
|
|
@@ -294,17 +299,38 @@ export async function createMainAgent(
|
|
|
294
299
|
// Tool and sub-agent collection are independent request-time fan-outs
|
|
295
300
|
// (each may open network connections); run them concurrently so the
|
|
296
301
|
// slower of the two — not their sum — gates the build.
|
|
302
|
+
//
|
|
303
|
+
// Observe-only domain context loads alongside them: the oracle's and the
|
|
304
|
+
// subject's anchored domain documents become a prompt block and two
|
|
305
|
+
// read tools. It grants nothing and gates nothing (see domain-context/).
|
|
297
306
|
const bootSubAgents = registries.subAgents.collectBoot(buildCtx);
|
|
298
|
-
const [allCollectedTools, requestSubAgents] = await Promise.all([
|
|
307
|
+
const [allCollectedTools, requestSubAgents, domain] = await Promise.all([
|
|
299
308
|
registries.tools.collect(buildCtx, rtCtx),
|
|
300
309
|
registries.subAgents.collectRequest(rtCtx),
|
|
310
|
+
domainContext?.mode === 'observe' && domainResolver
|
|
311
|
+
? prepareDomainContext({
|
|
312
|
+
options: domainContext,
|
|
313
|
+
resolver: domainResolver,
|
|
314
|
+
ctx: rtCtx,
|
|
315
|
+
oracleDid: identity.entityDid,
|
|
316
|
+
subjectDid: state.currentEntityDid,
|
|
317
|
+
signal: abortSignal ?? new AbortController().signal,
|
|
318
|
+
...(domainPins ? { pins: domainPins } : {}),
|
|
319
|
+
...(onDomainProvenance ? { onProvenance: onDomainProvenance } : {}),
|
|
320
|
+
})
|
|
321
|
+
: undefined,
|
|
301
322
|
]);
|
|
323
|
+
const domainTools = domain?.tools ?? [];
|
|
302
324
|
// A request-time tool or sub-agent never takes a name the server already
|
|
303
325
|
// uses this turn, so a client-declared name cannot shadow a server tool or
|
|
304
|
-
// overwrite its effect and repeatable classification below.
|
|
326
|
+
// overwrite its effect and repeatable classification below. The domain
|
|
327
|
+
// tools exist only on observe turns, so a boot-time plugin tool of the same
|
|
328
|
+
// name cannot be caught at boot: it is dropped for those turns instead of
|
|
329
|
+
// being bound twice.
|
|
305
330
|
const unshadowed = dropShadowingRequestEntries({
|
|
306
331
|
tools: allCollectedTools,
|
|
307
332
|
requestSubAgents,
|
|
333
|
+
runtimeNames: domainTools.map((t) => t.name),
|
|
308
334
|
reservedNames: [
|
|
309
335
|
...META_TOOL_NAMES,
|
|
310
336
|
READ_RESULT_TOOL_NAME,
|
|
@@ -457,6 +483,8 @@ export async function createMainAgent(
|
|
|
457
483
|
const repetitionCaps = repetitionCapsFromEnv(config);
|
|
458
484
|
const innerToolEffects = new Map<string, 'read' | 'write'>();
|
|
459
485
|
const innerRepeatable = new Set<string>();
|
|
486
|
+
// The domain tools only read documents (and drop a cached anchor).
|
|
487
|
+
for (const t of domainTools) innerToolEffects.set(t.name, 'read');
|
|
460
488
|
for (const { subAgent } of subAgentEntries)
|
|
461
489
|
for (const tool of Array.isArray(subAgent.tools) ? subAgent.tools : []) {
|
|
462
490
|
innerToolEffects.set(tool.name, toolEffectOf(tool));
|
|
@@ -491,6 +519,11 @@ export async function createMainAgent(
|
|
|
491
519
|
subAgents: subAgentEntries,
|
|
492
520
|
dispatchMiddleware,
|
|
493
521
|
...(resultCap ? { resultCap } : {}),
|
|
522
|
+
// Sub-agents see the same domain context: its block on their prompt,
|
|
523
|
+
// its tools beside their own.
|
|
524
|
+
...(domain
|
|
525
|
+
? { passthroughTools: domainTools, contextPrompt: domain.prompt }
|
|
526
|
+
: {}),
|
|
494
527
|
});
|
|
495
528
|
|
|
496
529
|
// Effect of every tool the model can call (durable runs: what may run
|
|
@@ -498,6 +531,7 @@ export async function createMainAgent(
|
|
|
498
531
|
// is opaque, hence a write.
|
|
499
532
|
const toolEffects = new Map<string, 'read' | 'write'>();
|
|
500
533
|
for (const t of metaTools) toolEffects.set(t.name, 'read');
|
|
534
|
+
for (const t of domainTools) toolEffects.set(t.name, 'read');
|
|
501
535
|
for (const { tool } of turnTools)
|
|
502
536
|
toolEffects.set(tool.name, toolEffectOf(tool));
|
|
503
537
|
for (const { tool } of allTools)
|
|
@@ -540,6 +574,7 @@ export async function createMainAgent(
|
|
|
540
574
|
...onDemandTools.map(wrap),
|
|
541
575
|
...silentTools.map(wrap),
|
|
542
576
|
...subAgentTools,
|
|
577
|
+
...domainTools,
|
|
543
578
|
];
|
|
544
579
|
|
|
545
580
|
// Lookups used by `CapabilityGateMiddleware` to gate on-demand plugins
|
|
@@ -622,8 +657,17 @@ export async function createMainAgent(
|
|
|
622
657
|
// (the middleware tags its call). The model itself keeps whatever
|
|
623
658
|
// transport its provider needs — forcing it non-streaming broke the
|
|
624
659
|
// ChatGPT backend, which accepts streamed requests only.
|
|
660
|
+
//
|
|
661
|
+
// The summary is written by the turn's own model (the `summarizer`
|
|
662
|
+
// role resolves to the `main` model on every lane, the per-request
|
|
663
|
+
// choice included), so it reads whatever the turn's window holds and a
|
|
664
|
+
// BYO turn's history stays on the user's provider. The platform adapter
|
|
665
|
+
// gives the role the helper calls' reasoning effort, not the reply's.
|
|
625
666
|
createSummarizationMiddleware({
|
|
626
|
-
model: resolveModel(
|
|
667
|
+
model: resolveModel(
|
|
668
|
+
'summarizer',
|
|
669
|
+
requestCtx.model ? { model: requestCtx.model } : undefined,
|
|
670
|
+
),
|
|
627
671
|
logger: ambient.logger,
|
|
628
672
|
// Window-derived thresholds when the host resolved a budget: summarize
|
|
629
673
|
// at a fraction of the model's window (tokens only, unless the
|
|
@@ -792,7 +836,7 @@ export async function createMainAgent(
|
|
|
792
836
|
})
|
|
793
837
|
.map(({ guide }) => guide);
|
|
794
838
|
|
|
795
|
-
const
|
|
839
|
+
const composedPrompt = await composePrompt({
|
|
796
840
|
identity,
|
|
797
841
|
capabilityBlock: tier1.block,
|
|
798
842
|
browserTools,
|
|
@@ -815,6 +859,11 @@ export async function createMainAgent(
|
|
|
815
859
|
),
|
|
816
860
|
operatingGuides,
|
|
817
861
|
});
|
|
862
|
+
// The domain block goes last: it frames its documents as retrieved data
|
|
863
|
+
// that never outranks the instructions above it.
|
|
864
|
+
const systemPrompt = domain?.prompt
|
|
865
|
+
? `${composedPrompt}\n\n${domain.prompt}`
|
|
866
|
+
: composedPrompt;
|
|
818
867
|
|
|
819
868
|
// ── 8. Model ────────────────────────────────────────────────────────────
|
|
820
869
|
// A per-request model (already allow-list-validated by the caller) wins
|
|
@@ -842,6 +891,14 @@ export async function createMainAgent(
|
|
|
842
891
|
toolEffects,
|
|
843
892
|
subAgentToolNames: new Set(subAgentTools.map((t) => t.name)),
|
|
844
893
|
context: runConfig.context,
|
|
894
|
+
...(domain
|
|
895
|
+
? {
|
|
896
|
+
domainContext: {
|
|
897
|
+
provenance: domain.provenance,
|
|
898
|
+
pins: domain.pins,
|
|
899
|
+
},
|
|
900
|
+
}
|
|
901
|
+
: {}),
|
|
845
902
|
};
|
|
846
903
|
}
|
|
847
904
|
|
|
@@ -29,6 +29,7 @@ import { ToolMessage } from '@langchain/core/messages';
|
|
|
29
29
|
import { type AgentMiddleware, createMiddleware } from 'langchain';
|
|
30
30
|
import type { Logger } from '../../plugin-api/types';
|
|
31
31
|
import type { ToolLane, ToolScheduler } from '../tool-scheduler';
|
|
32
|
+
import { findProviderStall } from '../../llm/stream-liveness';
|
|
32
33
|
import { isHarnessLimitError, type TurnBudget } from '../turn-budget';
|
|
33
34
|
import { NOOP_LOGGER } from '../utils';
|
|
34
35
|
|
|
@@ -148,6 +149,9 @@ export function isUncertainOutcome(
|
|
|
148
149
|
): boolean {
|
|
149
150
|
if (signal?.aborted) return true;
|
|
150
151
|
if (isHarnessLimitError(error)) return true;
|
|
152
|
+
// A tool whose own model call stalled (stream-liveness.ts) stopped at an
|
|
153
|
+
// unknown point of its work.
|
|
154
|
+
if (findProviderStall(error)) return true;
|
|
151
155
|
if (!(error instanceof Error)) return false;
|
|
152
156
|
if (error.name === 'AbortError' || error.name === 'TimeoutError') return true;
|
|
153
157
|
if (
|
|
@@ -273,9 +277,22 @@ export function createToolExecutionMiddleware(
|
|
|
273
277
|
// Refused before waiting for a slot: a turn over its limit takes no
|
|
274
278
|
// more slots. The slot wait itself ends with the abort signal.
|
|
275
279
|
budget.check(options.signal);
|
|
280
|
+
// Every completed call leaves a trace (a sub-agent's inner tools
|
|
281
|
+
// included), timed from when it got its slot.
|
|
282
|
+
const run = async (): Promise<Awaited<ReturnType<typeof handler>>> => {
|
|
283
|
+
const started = Date.now();
|
|
284
|
+
const output = await handler(request);
|
|
285
|
+
const ms = Date.now() - started;
|
|
286
|
+
logger.debug?.(
|
|
287
|
+
ToolMessage.isInstance(output) && output.status === 'error'
|
|
288
|
+
? `[tool-execution] ${toolName} (${callId}): returned an error in ${ms} ms`
|
|
289
|
+
: `[tool-execution] ${toolName} (${callId}): ok in ${ms} ms`,
|
|
290
|
+
);
|
|
291
|
+
return output;
|
|
292
|
+
};
|
|
276
293
|
return scheduler.run(lane, options.signal, async () => {
|
|
277
294
|
budget.reserveTool(options.signal);
|
|
278
|
-
if (lane !== 'write' || !claims) return
|
|
295
|
+
if (lane !== 'write' || !claims) return run();
|
|
279
296
|
const fingerprint = await operationKey(toolName, toolCall.args);
|
|
280
297
|
const claim = await claims.claimWrite({
|
|
281
298
|
fingerprint,
|
|
@@ -295,7 +312,7 @@ export function createToolExecutionMiddleware(
|
|
|
295
312
|
});
|
|
296
313
|
}
|
|
297
314
|
try {
|
|
298
|
-
const output = await
|
|
315
|
+
const output = await run();
|
|
299
316
|
const uncertain = uncertainResultReason(output);
|
|
300
317
|
if (uncertain !== null) {
|
|
301
318
|
logger.warn(
|