@animalabs/connectome-host 0.3.8 → 0.3.10

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.
@@ -0,0 +1,145 @@
1
+ // BedrockAdapter wrapper that appends each LLM call's request summary, stop
2
+ // reason, and any error to the same `llm-calls.<iso>.jsonl` file the
3
+ // Anthropic path uses. Purpose-built during the Aria bring-up (2026-07-21)
4
+ // when a 0-token assistant turn proved undiagnosable: the bedrock transport
5
+ // had no wire visibility at all.
6
+ //
7
+ // Kept deliberately lighter than LoggingAnthropicAdapter: request summaries
8
+ // always include tool NAMES (were tools attached at all? — the question that
9
+ // motivated this file), message/system sizes, and the stop sequences; the
10
+ // full raw request is retained only on errors. Responses log stop_reason,
11
+ // usage, and content-block shape (type + size per block) on every call.
12
+ //
13
+ // Each line: { type: 'call'|'error', kind: 'complete'|'stream', timestamp,
14
+ // durationMs, requestSummary, response?, rawRequest?, error? }
15
+
16
+ import { BedrockAdapter } from '@animalabs/membrane';
17
+ import type {
18
+ ProviderRequest,
19
+ ProviderResponse,
20
+ ProviderRequestOptions,
21
+ StreamCallbacks,
22
+ } from '@animalabs/membrane';
23
+ import { appendFileSync } from 'node:fs';
24
+
25
+ type BedrockConfig = ConstructorParameters<typeof BedrockAdapter>[0];
26
+
27
+ function summarizeRequest(request: ProviderRequest): Record<string, unknown> {
28
+ const msgs = (request.messages ?? []) as Array<{ role?: string; content?: unknown }>;
29
+ const last = msgs[msgs.length - 1];
30
+ const lastPreview = typeof last?.content === 'string'
31
+ ? last.content.slice(0, 200)
32
+ : Array.isArray(last?.content)
33
+ ? (last.content as Array<{ type?: string; text?: string }>)
34
+ .map((b) => (b.type === 'text' ? (b.text ?? '').slice(0, 120) : `[${b.type}]`))
35
+ .join(' | ').slice(0, 300)
36
+ : undefined;
37
+ return {
38
+ model: request.model,
39
+ maxTokens: request.maxTokens,
40
+ messageCount: msgs.length,
41
+ systemChars: typeof request.system === 'string'
42
+ ? request.system.length
43
+ : Array.isArray(request.system)
44
+ ? JSON.stringify(request.system).length
45
+ : 0,
46
+ toolNames: (request.tools as Array<{ name?: string }> | undefined)?.map((t) => t.name) ?? null,
47
+ toolCount: (request.tools as unknown[] | undefined)?.length ?? 0,
48
+ stopSequences: request.stopSequences ?? null,
49
+ lastMessageRole: last?.role,
50
+ lastMessagePreview: lastPreview,
51
+ };
52
+ }
53
+
54
+ function summarizeResponse(response: ProviderResponse): Record<string, unknown> {
55
+ const content = (response.content ?? []) as Array<{ type?: string; text?: string; name?: string }>;
56
+ return {
57
+ stopReason: response.stopReason ?? (response.raw as { stop_reason?: string } | undefined)?.stop_reason ?? null,
58
+ usage: response.usage ?? null,
59
+ blocks: content.map((b) => ({
60
+ type: b.type,
61
+ ...(b.type === 'text' ? { chars: (b.text ?? '').length } : {}),
62
+ ...(b.type === 'tool_use' ? { name: b.name } : {}),
63
+ })),
64
+ };
65
+ }
66
+
67
+ export class LoggingBedrockAdapter extends BedrockAdapter {
68
+ private readonly logPath: string;
69
+
70
+ constructor(config: BedrockConfig, logPath: string) {
71
+ super(config);
72
+ this.logPath = logPath;
73
+ }
74
+
75
+ private log(record: Record<string, unknown>): void {
76
+ try {
77
+ appendFileSync(this.logPath, JSON.stringify(record) + '\n');
78
+ } catch {
79
+ // Logging must never break inference.
80
+ }
81
+ }
82
+
83
+ override async complete(
84
+ request: ProviderRequest,
85
+ options?: ProviderRequestOptions,
86
+ ): Promise<ProviderResponse> {
87
+ const started = Date.now();
88
+ let rawRequest: unknown;
89
+ const opts: ProviderRequestOptions = {
90
+ ...options,
91
+ onRequest: (req) => { rawRequest = req; options?.onRequest?.(req); },
92
+ };
93
+ try {
94
+ const response = await super.complete(request, opts);
95
+ this.log({
96
+ type: 'call', kind: 'complete', timestamp: new Date(started).toISOString(),
97
+ durationMs: Date.now() - started,
98
+ requestSummary: summarizeRequest(request),
99
+ response: summarizeResponse(response),
100
+ });
101
+ return response;
102
+ } catch (error) {
103
+ this.log({
104
+ type: 'error', kind: 'complete', timestamp: new Date(started).toISOString(),
105
+ durationMs: Date.now() - started,
106
+ requestSummary: summarizeRequest(request),
107
+ rawRequest,
108
+ error: error instanceof Error ? { message: error.message, name: error.name } : String(error),
109
+ });
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ override async stream(
115
+ request: ProviderRequest,
116
+ callbacks: StreamCallbacks,
117
+ options?: ProviderRequestOptions,
118
+ ): Promise<ProviderResponse> {
119
+ const started = Date.now();
120
+ let rawRequest: unknown;
121
+ const opts: ProviderRequestOptions = {
122
+ ...options,
123
+ onRequest: (req) => { rawRequest = req; options?.onRequest?.(req); },
124
+ };
125
+ try {
126
+ const response = await super.stream(request, callbacks, opts);
127
+ this.log({
128
+ type: 'call', kind: 'stream', timestamp: new Date(started).toISOString(),
129
+ durationMs: Date.now() - started,
130
+ requestSummary: summarizeRequest(request),
131
+ response: summarizeResponse(response),
132
+ });
133
+ return response;
134
+ } catch (error) {
135
+ this.log({
136
+ type: 'error', kind: 'stream', timestamp: new Date(started).toISOString(),
137
+ durationMs: Date.now() - started,
138
+ requestSummary: summarizeRequest(request),
139
+ rawRequest,
140
+ error: error instanceof Error ? { message: error.message, name: error.name } : String(error),
141
+ });
142
+ throw error;
143
+ }
144
+ }
145
+ }
@@ -16,7 +16,8 @@
16
16
  * fan-out across many VMs are the reverse-proxy's job; an optional Basic-Auth
17
17
  * check is available as defense-in-depth.
18
18
  *
19
- * See WEBUI-PLAN.md and src/web/protocol.ts for the wire shape.
19
+ * See src/web/protocol.ts for the wire shape and docs/webui-deployment.md
20
+ * for the deployment story.
20
21
  */
21
22
 
22
23
  import { CURVE_PAGE_HTML } from './web-ui-curve-page.js';
@@ -57,6 +58,7 @@ import {
57
58
  type TokenUsage,
58
59
  type CallLedgerSnapshot,
59
60
  type McplListMessage,
61
+ type BranchesListMessage,
60
62
  type LessonsListMessage,
61
63
  } from '../web/protocol.js';
62
64
  import {
@@ -1547,6 +1549,9 @@ export class WebUiModule implements Module {
1547
1549
  if (client.auth !== 'observer') return false;
1548
1550
  if (type === 'ping') return true;
1549
1551
  if (type === 'request-history') return client.scopes?.has('messages') ?? false;
1552
+ // Branch listing is conversation-shape metadata (names, fork points) —
1553
+ // same sensitivity tier as the message window, so same scope.
1554
+ if (type === 'request-branches') return client.scopes?.has('messages') ?? false;
1550
1555
  return false; // observers are read-only: no user-message/command/mcpl/fleet
1551
1556
  }
1552
1557
 
@@ -1698,6 +1703,11 @@ export class WebUiModule implements Module {
1698
1703
  return;
1699
1704
  }
1700
1705
 
1706
+ case 'request-branches': {
1707
+ this.sendBranchesList(client);
1708
+ return;
1709
+ }
1710
+
1701
1711
  case 'mcpl-add': {
1702
1712
  try {
1703
1713
  const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
@@ -1983,6 +1993,40 @@ export class WebUiModule implements Module {
1983
1993
  this.send(client, out);
1984
1994
  }
1985
1995
 
1996
+ /** Build a BranchesListMessage from the agent's context manager. Lineage
1997
+ * (parentId + branchPoint) comes straight from Chronicle's branch records;
1998
+ * the SPA folds it into a tree. */
1999
+ private sendBranchesList(client: ClientState): void {
2000
+ if (!sharedServer?.app) return;
2001
+ const cm = sharedServer.app.framework.getAllAgents()[0]?.getContextManager();
2002
+ if (!cm) {
2003
+ this.send(client, { type: 'error', message: 'no agent context manager' });
2004
+ return;
2005
+ }
2006
+ try {
2007
+ const branches = cm.listBranches();
2008
+ const current = cm.currentBranch();
2009
+ const out: BranchesListMessage = {
2010
+ type: 'branches-list',
2011
+ branches: branches.map((b) => ({
2012
+ id: b.id,
2013
+ name: b.name,
2014
+ head: b.head,
2015
+ ...(b.parentId !== undefined ? { parentId: b.parentId } : {}),
2016
+ ...(b.branchPoint !== undefined ? { branchPoint: b.branchPoint } : {}),
2017
+ created: b.created instanceof Date ? b.created.getTime() : Number(b.created),
2018
+ })),
2019
+ currentId: current.id,
2020
+ };
2021
+ this.send(client, out);
2022
+ } catch (err) {
2023
+ this.send(client, {
2024
+ type: 'error',
2025
+ message: `branch listing failed: ${err instanceof Error ? err.message : String(err)}`,
2026
+ });
2027
+ }
2028
+ }
2029
+
1986
2030
  /** Build a LessonsListMessage from the bound LessonsModule, if present. */
1987
2031
  private sendLessonsList(client: ClientState): void {
1988
2032
  if (!sharedServer?.app) return;
package/src/recipe.ts CHANGED
@@ -19,7 +19,11 @@ import { dirname, isAbsolute, resolve } from 'node:path';
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
21
  export interface RecipeStrategy {
22
- type: 'autobiographical' | 'passthrough' | 'frontdesk';
22
+ /** Built-in strategy name, or a custom type registered by a
23
+ * `kind: "strategy"` extension (see `Recipe.extensions`). The
24
+ * `string & {}` arm keeps literal autocompletion while admitting
25
+ * extension-registered names. */
26
+ type: 'autobiographical' | 'passthrough' | 'frontdesk' | (string & {});
23
27
  // Core window/compression sizing
24
28
  headWindowTokens?: number;
25
29
  recentWindowTokens?: number;
@@ -85,7 +89,15 @@ export interface RecipeAgent {
85
89
  /** IANA zone used when rendering wall-clock times to the agent. */
86
90
  timezone?: string;
87
91
  /** Provider transport. Omitted preserves the historical Anthropic default. */
88
- provider?: 'anthropic' | 'openai-responses';
92
+ provider?: 'anthropic' | 'openai-responses' | 'openai-codex' | 'openrouter' | 'bedrock';
93
+ /** Message formatter. 'native' (default) = structured user/assistant turns.
94
+ * 'anthropic-xml' = classic prefill format ("participant: text" runs, XML
95
+ * tools) — for migrating prefill-era bots (chapterx borgs) with their exact
96
+ * prompting structure intact. */
97
+ formatter?: 'native' | 'anthropic-xml';
98
+ /** Prefill scaffold user message appended after the conversation (e.g.
99
+ * chapterx CLI-sim's "<cmd>cat untitled.txt</cmd>"). Prefill formatter only. */
100
+ prefillUserMessage?: string;
89
101
  systemPrompt: string;
90
102
  maxTokens?: number;
91
103
  /**
@@ -105,6 +117,13 @@ export interface RecipeAgent {
105
117
  * Omitted preserves the compatibility carry-forward in Agent Framework.
106
118
  */
107
119
  sameRoundThinkTextPolicy?: 'public' | 'private';
120
+ /**
121
+ * Extra Anthropic beta flags sent as the `anthropic-beta` header on every
122
+ * request (e.g. `["context-1m-2025-08-07"]` for the 1M context window on
123
+ * models where it is opt-in, like claude-sonnet-4-5). Merged with any
124
+ * transport-required betas (OAuth). Anthropic provider only.
125
+ */
126
+ anthropicBetas?: string[];
108
127
  strategy?: RecipeStrategy;
109
128
  /**
110
129
  * Native extended thinking. When `enabled: true`, the agent's API requests
@@ -115,13 +134,20 @@ export interface RecipeAgent {
115
134
  enabled: boolean;
116
135
  budgetTokens?: number;
117
136
  };
118
- /** Stateless OpenAI Responses settings. Only used with
119
- * `provider: "openai-responses"`. */
137
+ /** OpenAI Responses settings. Reasoning applies to both OpenAI providers;
138
+ * compaction and serviceTier are API-key transport settings. */
120
139
  responses?: {
121
140
  reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
122
141
  reasoningContext?: 'current_turn' | 'all_turns';
123
- serviceTier?: string;
124
142
  compactThreshold?: number;
143
+ /** OpenAI Responses API processing tier. `priority` is the API-key
144
+ * equivalent of Codex fast mode. */
145
+ serviceTier?: 'auto' | 'default' | 'flex' | 'priority';
146
+ };
147
+ /** ChatGPT-subscription Codex settings. Only used with
148
+ * `provider: "openai-codex"`. Runtime `/fast` overrides fastMode. */
149
+ codex?: {
150
+ fastMode?: boolean;
125
151
  };
126
152
  /**
127
153
  * Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
@@ -132,11 +158,6 @@ export interface RecipeAgent {
132
158
  autoRewind?: boolean;
133
159
  maxRewinds?: number;
134
160
  announceHumanTurns?: boolean;
135
- primarySummaryFallback?: {
136
- enabled?: boolean;
137
- maxNewSummaries?: number;
138
- requestBudgetTokens?: number;
139
- };
140
161
  };
141
162
  }
142
163
 
@@ -508,6 +529,31 @@ export interface RecipeFleetChild {
508
529
  autoRestart?: boolean;
509
530
  }
510
531
 
532
+ /**
533
+ * A deployment-specific code extension: a local TS/JS module that registers
534
+ * custom context strategies and/or framework modules at boot. See
535
+ * src/extensions.ts for the loader and the `register(api, config)` contract.
536
+ */
537
+ export interface RecipeExtension {
538
+ /** Primary purpose. Gates validation (a non-built-in `agent.strategy.type`
539
+ * requires at least one `kind: "strategy"` extension) and informs build
540
+ * tooling; does not restrict what `register` may call. */
541
+ kind: 'strategy' | 'module';
542
+ /** Path to the extension entry module. Relative paths resolve against the
543
+ * recipe file's directory at load time; URL-loaded recipes must use
544
+ * absolute paths (the host never imports code over the network). */
545
+ path: string;
546
+ /** Opaque blob passed verbatim to the extension's `register` function and
547
+ * to module factory contexts. */
548
+ config?: Record<string, unknown>;
549
+ /** Build-tooling acquisition metadata (git url/ref/install pattern) —
550
+ * consumed by connectome-cook at build time, ignored at runtime. */
551
+ source?: Record<string, unknown>;
552
+ /** Build-only provenance: `source` demoted by build tooling into the
553
+ * shipped recipe. Ignored at runtime. */
554
+ sourceMeta?: Record<string, unknown>;
555
+ }
556
+
511
557
  export interface Recipe {
512
558
  name: string;
513
559
  description?: string;
@@ -515,6 +561,8 @@ export interface Recipe {
515
561
  agent: RecipeAgent;
516
562
  mcpServers?: Record<string, RecipeMcpServer>;
517
563
  modules?: RecipeModules;
564
+ /** Deployment-specific code extensions, keyed by a human-readable name. */
565
+ extensions?: Record<string, RecipeExtension>;
518
566
  sessionNaming?: { examples?: string[] };
519
567
  }
520
568
 
@@ -644,6 +692,7 @@ export async function loadRecipe(source: string): Promise<Recipe> {
644
692
  raw = substituteEnvVars(raw, source);
645
693
  const recipe = validateRecipe(raw);
646
694
  resolveChildRecipePaths(recipe, sourceBase);
695
+ resolveExtensionPaths(recipe, sourceBase);
647
696
  return resolveSystemPrompt(recipe);
648
697
  }
649
698
 
@@ -668,6 +717,25 @@ function resolveChildRecipePaths(recipe: Recipe, base: RecipeSourceBase): void {
668
717
  }
669
718
  }
670
719
 
720
+ /**
721
+ * Resolve relative `extensions[].path` values against the recipe file's
722
+ * directory. URL-loaded recipes cannot carry relative extension paths — the
723
+ * host never fetches code over the network, so there is nothing local to
724
+ * resolve them against.
725
+ */
726
+ function resolveExtensionPaths(recipe: Recipe, base: RecipeSourceBase): void {
727
+ for (const [name, ext] of Object.entries(recipe.extensions ?? {})) {
728
+ if (isAbsolute(ext.path)) continue;
729
+ if (base.kind === 'url') {
730
+ throw new Error(
731
+ `extensions.${name}.path "${ext.path}" is relative, but the recipe was loaded from a URL. ` +
732
+ `Extension code is never fetched remotely — use an absolute path to a local install.`,
733
+ );
734
+ }
735
+ ext.path = resolve(base.dir, ext.path);
736
+ }
737
+ }
738
+
671
739
  /**
672
740
  * If systemPrompt is an HTTP(S) URL, fetch its contents as plain text.
673
741
  * Only treated as a URL if it looks like a single URL (no spaces/newlines).
@@ -706,8 +774,16 @@ export function validateRecipe(raw: unknown): Recipe {
706
774
  throw new Error('Recipe agent must have a "systemPrompt" string');
707
775
  }
708
776
 
709
- if (agent.provider !== undefined && agent.provider !== 'anthropic' && agent.provider !== 'openai-responses') {
710
- throw new Error(`Recipe agent.provider must be 'anthropic' or 'openai-responses', got ${JSON.stringify(agent.provider)}.`);
777
+ if (agent.provider !== undefined &&
778
+ agent.provider !== 'anthropic' &&
779
+ agent.provider !== 'openai-responses' &&
780
+ agent.provider !== 'openai-codex' &&
781
+ agent.provider !== 'openrouter' &&
782
+ agent.provider !== 'bedrock') {
783
+ throw new Error(
784
+ `Recipe agent.provider must be 'anthropic', 'openai-responses', 'openai-codex', 'openrouter', or 'bedrock', ` +
785
+ `got ${JSON.stringify(agent.provider)}.`,
786
+ );
711
787
  }
712
788
 
713
789
  if (agent.timezone !== undefined) {
@@ -740,6 +816,20 @@ export function validateRecipe(raw: unknown): Recipe {
740
816
  (typeof responses.compactThreshold !== 'number' || responses.compactThreshold <= 0)) {
741
817
  throw new Error('Recipe agent.responses.compactThreshold must be a positive number.');
742
818
  }
819
+ const serviceTiers = ['auto', 'default', 'flex', 'priority'];
820
+ if (responses.serviceTier !== undefined && !serviceTiers.includes(String(responses.serviceTier))) {
821
+ throw new Error(`Invalid agent.responses.serviceTier ${JSON.stringify(responses.serviceTier)}.`);
822
+ }
823
+ }
824
+
825
+ if (agent.codex !== undefined) {
826
+ if (!agent.codex || typeof agent.codex !== 'object' || Array.isArray(agent.codex)) {
827
+ throw new Error('Recipe agent.codex must be an object.');
828
+ }
829
+ const codex = agent.codex as Record<string, unknown>;
830
+ if (codex.fastMode !== undefined && typeof codex.fastMode !== 'boolean') {
831
+ throw new Error('Recipe agent.codex.fastMode must be a boolean.');
832
+ }
743
833
  }
744
834
 
745
835
  if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
@@ -787,6 +877,37 @@ export function validateRecipe(raw: unknown): Recipe {
787
877
  }
788
878
  }
789
879
 
880
+ // Validate the extensions block if present (before the strategy check,
881
+ // which consults it to admit custom strategy types).
882
+ let hasStrategyExtension = false;
883
+ if (obj.extensions !== undefined) {
884
+ if (!obj.extensions || typeof obj.extensions !== 'object' || Array.isArray(obj.extensions)) {
885
+ throw new Error('Recipe "extensions" must be an object mapping names to extension entries.');
886
+ }
887
+ for (const [name, entry] of Object.entries(obj.extensions as Record<string, unknown>)) {
888
+ if (!name) throw new Error('Recipe extensions keys must be non-empty names.');
889
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
890
+ throw new Error(`extensions.${name} must be an object.`);
891
+ }
892
+ const ext = entry as Record<string, unknown>;
893
+ if (ext.kind !== 'strategy' && ext.kind !== 'module') {
894
+ throw new Error(`extensions.${name}.kind must be "strategy" or "module", got ${JSON.stringify(ext.kind)}.`);
895
+ }
896
+ if (typeof ext.path !== 'string' || !ext.path) {
897
+ throw new Error(`extensions.${name}.path must be a non-empty string.`);
898
+ }
899
+ if (ext.config !== undefined && (!ext.config || typeof ext.config !== 'object' || Array.isArray(ext.config))) {
900
+ throw new Error(`extensions.${name}.config must be an object.`);
901
+ }
902
+ for (const metaKey of ['source', 'sourceMeta'] as const) {
903
+ if (ext[metaKey] !== undefined && (!ext[metaKey] || typeof ext[metaKey] !== 'object' || Array.isArray(ext[metaKey]))) {
904
+ throw new Error(`extensions.${name}.${metaKey} must be an object.`);
905
+ }
906
+ }
907
+ if (ext.kind === 'strategy') hasStrategyExtension = true;
908
+ }
909
+ }
910
+
790
911
  // Validate strategy type if present
791
912
  if (agent.strategy) {
792
913
  const strategy = agent.strategy as Record<string, unknown>;
@@ -794,10 +915,13 @@ export function validateRecipe(raw: unknown): Recipe {
794
915
  strategy.type &&
795
916
  strategy.type !== 'autobiographical' &&
796
917
  strategy.type !== 'passthrough' &&
797
- strategy.type !== 'frontdesk'
918
+ strategy.type !== 'frontdesk' &&
919
+ !hasStrategyExtension
798
920
  ) {
799
921
  throw new Error(
800
- `Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough", or "frontdesk".`,
922
+ `Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough", ` +
923
+ `or "frontdesk" — or a custom type registered by a "kind": "strategy" entry in the ` +
924
+ `recipe's "extensions" block (none is declared).`,
801
925
  );
802
926
  }
803
927
  if (
@@ -823,35 +947,8 @@ export function validateRecipe(raw: unknown): Recipe {
823
947
  }
824
948
 
825
949
  const refusalHandling = agent.refusalHandling as Record<string, unknown> | undefined;
826
- const primarySummaryFallback = refusalHandling?.primarySummaryFallback;
827
- if (primarySummaryFallback !== undefined) {
828
- if (!primarySummaryFallback || typeof primarySummaryFallback !== 'object' || Array.isArray(primarySummaryFallback)) {
829
- throw new Error('Recipe agent.refusalHandling.primarySummaryFallback must be an object.');
830
- }
831
- const fallback = primarySummaryFallback as Record<string, unknown>;
832
- if (fallback.enabled !== undefined && typeof fallback.enabled !== 'boolean') {
833
- throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.enabled must be a boolean.');
834
- }
835
- if (
836
- fallback.maxNewSummaries !== undefined &&
837
- (
838
- typeof fallback.maxNewSummaries !== 'number' ||
839
- !Number.isSafeInteger(fallback.maxNewSummaries) ||
840
- fallback.maxNewSummaries < 0
841
- )
842
- ) {
843
- throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.maxNewSummaries must be a non-negative safe integer.');
844
- }
845
- if (
846
- fallback.requestBudgetTokens !== undefined &&
847
- (
848
- typeof fallback.requestBudgetTokens !== 'number' ||
849
- !Number.isSafeInteger(fallback.requestBudgetTokens) ||
850
- fallback.requestBudgetTokens <= 0
851
- )
852
- ) {
853
- throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.requestBudgetTokens must be a positive safe integer.');
854
- }
950
+ if (refusalHandling && Object.hasOwn(refusalHandling, 'primarySummaryFallback')) {
951
+ throw new Error('Recipe agent.refusalHandling.primarySummaryFallback was removed and is unsupported.');
855
952
  }
856
953
 
857
954
  // Validate mcpServers entries if present