@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.
Files changed (40) hide show
  1. package/README.md +3 -0
  2. package/package.json +5 -3
  3. package/src/core/budgeted-llm.ts +115 -6
  4. package/src/core/context-budget.ts +24 -26
  5. package/src/core/context-window.ts +39 -12
  6. package/src/core/domain-context/document.ts +100 -0
  7. package/src/core/domain-context/findings.ts +25 -0
  8. package/src/core/domain-context/index.ts +28 -0
  9. package/src/core/domain-context/integrity.ts +60 -0
  10. package/src/core/domain-context/lru.ts +63 -0
  11. package/src/core/domain-context/resolver.ts +663 -0
  12. package/src/core/domain-context/transport.ts +382 -0
  13. package/src/core/domain-context/turn-context.ts +443 -0
  14. package/src/core/domain-context/types.ts +119 -0
  15. package/src/core/domain-context/validator.ts +41 -0
  16. package/src/core/env.ts +60 -6
  17. package/src/core/index.ts +2 -0
  18. package/src/core/llm.ts +225 -4
  19. package/src/core/main-agent-types.ts +28 -0
  20. package/src/core/main-agent.ts +61 -4
  21. package/src/core/middlewares/tool-execution.ts +19 -2
  22. package/src/core/registries.ts +14 -3
  23. package/src/core/state.ts +2 -1
  24. package/src/core/sub-agent-fallback.ts +14 -2
  25. package/src/core/turn-budget.ts +19 -22
  26. package/src/do/contracts.ts +23 -2
  27. package/src/do/domain-context-store.ts +123 -0
  28. package/src/do/run-request.ts +6 -0
  29. package/src/do/sse-stream.ts +1 -1
  30. package/src/do/transcript.ts +9 -0
  31. package/src/do/turn-body.ts +2 -1
  32. package/src/do/turn-metadata.ts +13 -6
  33. package/src/do/user-oracle-do.ts +111 -42
  34. package/src/index.ts +16 -0
  35. package/src/llm/byo-adapter.ts +6 -0
  36. package/src/llm/byo-catalog.ts +7 -4
  37. package/src/llm/byo-client.ts +42 -7
  38. package/src/llm/provider-error.ts +21 -3
  39. package/src/llm/stream-liveness.ts +498 -0
  40. 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
- /** OpenRouter role map. `main` is resolved through `getDefaultModelId`. */
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
- ...asRecord(paramsConfiguration),
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
  }
@@ -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('routing'),
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 systemPrompt = await composePrompt({
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 handler(request);
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 handler(request);
315
+ const output = await run();
299
316
  const uncertain = uncertainResultReason(output);
300
317
  if (uncertain !== null) {
301
318
  logger.warn(