@framers/agentos 0.12.3 → 0.12.5

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 (119) hide show
  1. package/dist/api/agent.d.ts +7 -5
  2. package/dist/api/agent.d.ts.map +1 -1
  3. package/dist/api/agent.js.map +1 -1
  4. package/dist/api/generateObject.d.ts +5 -5
  5. package/dist/api/generateText.d.ts +173 -50
  6. package/dist/api/generateText.d.ts.map +1 -1
  7. package/dist/api/generateText.js +272 -81
  8. package/dist/api/generateText.js.map +1 -1
  9. package/dist/api/index.d.ts +6 -1
  10. package/dist/api/index.d.ts.map +1 -1
  11. package/dist/api/index.js +5 -1
  12. package/dist/api/index.js.map +1 -1
  13. package/dist/api/runtime/GMIChunkTransformer.d.ts +1 -1
  14. package/dist/api/runtime/GMIChunkTransformer.d.ts.map +1 -1
  15. package/dist/api/runtime/GMIChunkTransformer.js +7 -2
  16. package/dist/api/runtime/GMIChunkTransformer.js.map +1 -1
  17. package/dist/api/runtime/completionGateway.d.ts +98 -0
  18. package/dist/api/runtime/completionGateway.d.ts.map +1 -0
  19. package/dist/api/runtime/completionGateway.js +347 -0
  20. package/dist/api/runtime/completionGateway.js.map +1 -0
  21. package/dist/api/runtime/contextWindowFit.d.ts +49 -0
  22. package/dist/api/runtime/contextWindowFit.d.ts.map +1 -0
  23. package/dist/api/runtime/contextWindowFit.js +109 -0
  24. package/dist/api/runtime/contextWindowFit.js.map +1 -0
  25. package/dist/api/runtime/gatewayProviderManager.d.ts +37 -0
  26. package/dist/api/runtime/gatewayProviderManager.d.ts.map +1 -0
  27. package/dist/api/runtime/gatewayProviderManager.js +49 -0
  28. package/dist/api/runtime/gatewayProviderManager.js.map +1 -0
  29. package/dist/api/streamText.d.ts.map +1 -1
  30. package/dist/api/streamText.js +90 -6
  31. package/dist/api/streamText.js.map +1 -1
  32. package/dist/cognition/emergent/ComposableToolBuilder.d.ts.map +1 -1
  33. package/dist/cognition/emergent/ComposableToolBuilder.js +16 -8
  34. package/dist/cognition/emergent/ComposableToolBuilder.js.map +1 -1
  35. package/dist/cognition/emergent/EffectsStore.d.ts +58 -0
  36. package/dist/cognition/emergent/EffectsStore.d.ts.map +1 -0
  37. package/dist/cognition/emergent/EffectsStore.js +96 -0
  38. package/dist/cognition/emergent/EffectsStore.js.map +1 -0
  39. package/dist/cognition/emergent/EmergentCapabilityEngine.d.ts +22 -2
  40. package/dist/cognition/emergent/EmergentCapabilityEngine.d.ts.map +1 -1
  41. package/dist/cognition/emergent/EmergentCapabilityEngine.js +128 -19
  42. package/dist/cognition/emergent/EmergentCapabilityEngine.js.map +1 -1
  43. package/dist/cognition/emergent/EmergentJudge.d.ts +1 -1
  44. package/dist/cognition/emergent/EmergentJudge.d.ts.map +1 -1
  45. package/dist/cognition/emergent/EmergentToolRegistry.d.ts +12 -3
  46. package/dist/cognition/emergent/EmergentToolRegistry.d.ts.map +1 -1
  47. package/dist/cognition/emergent/EmergentToolRegistry.js +51 -4
  48. package/dist/cognition/emergent/EmergentToolRegistry.js.map +1 -1
  49. package/dist/cognition/emergent/ForgeToolMetaTool.d.ts.map +1 -1
  50. package/dist/cognition/emergent/ForgeToolMetaTool.js +4 -1
  51. package/dist/cognition/emergent/ForgeToolMetaTool.js.map +1 -1
  52. package/dist/cognition/emergent/SandboxedToolForge.d.ts +55 -51
  53. package/dist/cognition/emergent/SandboxedToolForge.d.ts.map +1 -1
  54. package/dist/cognition/emergent/SandboxedToolForge.js +103 -109
  55. package/dist/cognition/emergent/SandboxedToolForge.js.map +1 -1
  56. package/dist/cognition/emergent/ToolPackage.d.ts +2 -2
  57. package/dist/cognition/emergent/ToolPackage.d.ts.map +1 -1
  58. package/dist/cognition/emergent/broker/CapabilityBroker.d.ts +58 -0
  59. package/dist/cognition/emergent/broker/CapabilityBroker.d.ts.map +1 -0
  60. package/dist/cognition/emergent/broker/CapabilityBroker.js +288 -0
  61. package/dist/cognition/emergent/broker/CapabilityBroker.js.map +1 -0
  62. package/dist/cognition/emergent/broker/fetch.d.ts +44 -0
  63. package/dist/cognition/emergent/broker/fetch.d.ts.map +1 -0
  64. package/dist/cognition/emergent/broker/fetch.js +197 -0
  65. package/dist/cognition/emergent/broker/fetch.js.map +1 -0
  66. package/dist/cognition/emergent/broker/fs-read.d.ts +63 -0
  67. package/dist/cognition/emergent/broker/fs-read.d.ts.map +1 -0
  68. package/dist/cognition/emergent/broker/fs-read.js +133 -0
  69. package/dist/cognition/emergent/broker/fs-read.js.map +1 -0
  70. package/dist/cognition/emergent/broker/refusal.d.ts +10 -0
  71. package/dist/cognition/emergent/broker/refusal.d.ts.map +1 -0
  72. package/dist/cognition/emergent/broker/refusal.js +13 -0
  73. package/dist/cognition/emergent/broker/refusal.js.map +1 -0
  74. package/dist/cognition/emergent/ceiling.d.ts +75 -0
  75. package/dist/cognition/emergent/ceiling.d.ts.map +1 -0
  76. package/dist/cognition/emergent/ceiling.js +166 -0
  77. package/dist/cognition/emergent/ceiling.js.map +1 -0
  78. package/dist/cognition/emergent/index.d.ts +2 -0
  79. package/dist/cognition/emergent/index.d.ts.map +1 -1
  80. package/dist/cognition/emergent/index.js +1 -0
  81. package/dist/cognition/emergent/index.js.map +1 -1
  82. package/dist/cognition/emergent/types.d.ts +75 -2
  83. package/dist/cognition/emergent/types.d.ts.map +1 -1
  84. package/dist/cognition/emergent/types.js.map +1 -1
  85. package/dist/cognition/substrate/GMI.d.ts +21 -0
  86. package/dist/cognition/substrate/GMI.d.ts.map +1 -1
  87. package/dist/cognition/substrate/GMI.js +286 -88
  88. package/dist/cognition/substrate/GMI.js.map +1 -1
  89. package/dist/cognition/substrate/IGMI.d.ts +48 -1
  90. package/dist/cognition/substrate/IGMI.d.ts.map +1 -1
  91. package/dist/cognition/substrate/IGMI.js +4 -0
  92. package/dist/cognition/substrate/IGMI.js.map +1 -1
  93. package/dist/core/llm/providers/errors/ContextWindowExceededError.d.ts +22 -0
  94. package/dist/core/llm/providers/errors/ContextWindowExceededError.d.ts.map +1 -0
  95. package/dist/core/llm/providers/errors/ContextWindowExceededError.js +21 -0
  96. package/dist/core/llm/providers/errors/ContextWindowExceededError.js.map +1 -0
  97. package/dist/core/llm/providers/implementations/OpenRouterProvider.d.ts.map +1 -1
  98. package/dist/core/llm/providers/implementations/OpenRouterProvider.js +34 -13
  99. package/dist/core/llm/providers/implementations/OpenRouterProvider.js.map +1 -1
  100. package/dist/core/llm/routing/IModelRouter.d.ts +6 -0
  101. package/dist/core/llm/routing/IModelRouter.d.ts.map +1 -1
  102. package/dist/core/llm/routing/PolicyAwareRouter.d.ts +7 -0
  103. package/dist/core/llm/routing/PolicyAwareRouter.d.ts.map +1 -1
  104. package/dist/core/llm/routing/PolicyAwareRouter.js +12 -0
  105. package/dist/core/llm/routing/PolicyAwareRouter.js.map +1 -1
  106. package/dist/core/llm/routing/UncensoredModelCatalog.d.ts +37 -0
  107. package/dist/core/llm/routing/UncensoredModelCatalog.d.ts.map +1 -1
  108. package/dist/core/llm/routing/UncensoredModelCatalog.js +64 -74
  109. package/dist/core/llm/routing/UncensoredModelCatalog.js.map +1 -1
  110. package/dist/core/tools/ITool.d.ts +45 -0
  111. package/dist/core/tools/ITool.d.ts.map +1 -1
  112. package/dist/core/tools/ToolOrchestrator.d.ts.map +1 -1
  113. package/dist/core/tools/ToolOrchestrator.js +4 -8
  114. package/dist/core/tools/ToolOrchestrator.js.map +1 -1
  115. package/dist/index.d.ts +5 -0
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +4 -0
  118. package/dist/index.js.map +1 -1
  119. package/package.json +1 -1
@@ -12,7 +12,7 @@
12
12
  * the system prompt so the tool loop executes with awareness of the strategy.
13
13
  */
14
14
  import { randomUUID } from 'node:crypto';
15
- import { resolveModelOption, resolveProvider, createProviderManager } from './model.js';
15
+ import { resolveModelOption, resolveProvider, createProviderManager, knownProviderPrefixOf } from './model.js';
16
16
  import { attachGenAiAttributes, attachUsageAttributes, toTurnMetricUsage } from './observability.js';
17
17
  import { fireLlmUsageObserver } from './observers.js';
18
18
  import { hostPolicyToRouteParams, mergeRequiredCapabilities, } from './runtime/hostPolicy.js';
@@ -24,6 +24,9 @@ import { recordAgentOSTurnMetrics, withAgentOSSpan } from '../safety/evaluation/
24
24
  import { createLogger } from '../core/logging/loggerFactory.js';
25
25
  import { globalLLMProviderHealth } from '../core/safety/LLMProviderHealthRegistry.js';
26
26
  import { CONTEXT_WINDOW_EXCEEDED_CODE } from '../core/llm/providers/errors/errorCodes.js';
27
+ import { ContextWindowExceededError } from '../core/llm/providers/errors/ContextWindowExceededError.js';
28
+ import { catalogEntryHasCapability, createUncensoredModelCatalog, findCatalogTextModel, } from '../core/llm/routing/UncensoredModelCatalog.js';
29
+ import { checkContextFit } from './runtime/contextWindowFit.js';
27
30
  import { describeResponseFormatShape } from './runtime/responseFormatForProvider.js';
28
31
  const fallbackLogger = createLogger('fallback');
29
32
  /**
@@ -324,7 +327,7 @@ export function addModelUsage(target, usage) {
324
327
  * The usage a provider error reports for the request it ended, such as the
325
328
  * billed tokens of a refused turn (`details.usage`).
326
329
  *
327
- * @internal Shared with streamText.
330
+ * @internal Shared with streamText and the completion gateway.
328
331
  */
329
332
  export function usageOfError(error) {
330
333
  return error?.details?.usage;
@@ -662,81 +665,193 @@ export function fallbackHopOverrides(opts, entry) {
662
665
  };
663
666
  }
664
667
  /**
665
- * Build a policy-tier-aware fallback chain. Used by callers that pass
666
- * `policyTier: 'mature' | 'private-adult'` so refusals from the
667
- * primary model (typically gpt-4o, Claude, Gemini: all of which
668
- * moderate explicit content) re-route to an uncensored OpenRouter
669
- * model instead of hard-failing the request.
668
+ * The policy tier a call's fallback chain is built for: the explicit route
669
+ * tier, the call's tier, the host policy's tier (`standard` when a host
670
+ * policy names none), then the router's default. The primary's route block
671
+ * sends the first three terms only, so a delegated base router still sees no
672
+ * tier when no explicit source set one.
673
+ */
674
+ export function resolvePolicyTier(opts) {
675
+ return (opts.routerParams?.policyTier ??
676
+ opts.policyTier ??
677
+ hostPolicyToRouteParams(opts.hostPolicy).policyTier ??
678
+ opts.router?.policyTier);
679
+ }
680
+ /** @internal */
681
+ export const INITIAL_FALLBACK_WALK = Object.freeze({
682
+ refillsOwed: 0,
683
+ refusalSeen: false,
684
+ });
685
+ /** Claude, direct or through OpenRouter. */
686
+ function isClaudeEntry(entry) {
687
+ return (entry.provider === 'anthropic' ||
688
+ (entry.provider === 'openrouter' && (entry.model ?? '').startsWith('anthropic/')));
689
+ }
690
+ /**
691
+ * The capabilities a call names explicitly (host policy and route params).
692
+ * Native tool calling is not inferred from the presence of tools: a model
693
+ * without it serves a tool-carrying call through the prompt shim.
670
694
  *
671
- * Chain order for mature / private-adult:
672
- * 1. `nousresearch/hermes-3-llama-3.1-405b` on OpenRouter: leads
673
- * the uncensored leaderboard for instruction-following + long-
674
- * context comprehension. Same model the wilds-ai
675
- * companion-pipeline uses for identity generation on mature+
676
- * companions; battle-tested on real workloads.
677
- * 2. `anthropic/claude-sonnet-4` on OpenRouter: Claude refuses
678
- * hard NSFW but is markedly more permissive than gpt-4o for
679
- * narrative analysis of explicit fiction (extracting characters
680
- * from a CAI-export with mild adult content, etc.). Acts as the
681
- * headroom band when Hermes 3 is rate-limited or down.
682
- * 3. The standard {@link buildFallbackChain} suffix: keeps
683
- * availability fallback on top of the policy fallback so a
684
- * mature request that hits a Hermes 3 outage AND a Sonnet
685
- * outage still has gpt-4o-mini etc. to fall back to (which
686
- * will refuse on the explicit case but at least surfaces a
687
- * moderation error rather than a network error).
695
+ * @internal
696
+ */
697
+ export function explicitRequiredCapabilities(opts) {
698
+ return (mergeRequiredCapabilities(hostPolicyToRouteParams(opts.hostPolicy).requiredCapabilities, opts.routerParams?.requiredCapabilities) ?? []);
699
+ }
700
+ /**
701
+ * The provider and model a fallback entry is sent as: resolved the way the
702
+ * leg's own call resolves it (a provider's default model when the entry
703
+ * names none, a `provider:model` id split), or the entry as written when it
704
+ * does not resolve.
688
705
  *
689
- * For `safe` / `standard` tiers, this is identical to
690
- * {@link buildFallbackChain}: no uncensored prefix needed. Callers
691
- * that don't pass a tier should keep using the original builder.
706
+ * @internal Shared with streamText.
707
+ */
708
+ export function fallbackEntrySentAs(entry) {
709
+ try {
710
+ const { providerId, modelId } = resolveModelOption({ provider: entry.provider, model: entry.model }, 'text');
711
+ return { provider: providerId, model: modelId };
712
+ }
713
+ catch {
714
+ return { provider: entry.provider, model: entry.model };
715
+ }
716
+ }
717
+ /**
718
+ * Resolve a call's fallback chain once, at the start of the top-level walk.
719
+ * Policy-chain entries (`origin: 'policy-default'`) naming the failed first
720
+ * model are dropped; every entry is checked, as the provider and model it
721
+ * will be sent as, against the call's explicitly required capabilities and
722
+ * excluded models (a model outside the catalog counts as capable); the
723
+ * uncensored group's first two remaining entries become standing legs and
724
+ * the rest refills. Caller-written entries keep their order.
692
725
  *
693
- * Auto-built fallbacks always require their own env keys; missing
694
- * keys silently drop the entry rather than throwing, so a partial
695
- * deploy still produces a usable (shorter) chain.
726
+ * @internal
727
+ */
728
+ export function resolveFallbackChain(chain, ctx) {
729
+ const required = ctx.requiredCapabilities ?? [];
730
+ // An exclusion names a model as written or as a `provider:model` id; a leg
731
+ // matches by its model as written and as it is sent.
732
+ const excluded = new Set();
733
+ for (const id of ctx.excludedModelIds ?? []) {
734
+ excluded.add(id);
735
+ const prefix = knownProviderPrefixOf(id);
736
+ if (prefix)
737
+ excluded.add(id.slice(prefix.length + 1));
738
+ }
739
+ const isExcluded = (model) => model !== undefined && excluded.has(model);
740
+ const resolved = [];
741
+ let uncensoredLegs = 0;
742
+ for (const entry of chain) {
743
+ if (entry.origin === 'policy-default' &&
744
+ entry.provider === ctx.primary.provider &&
745
+ entry.model === ctx.primary.model) {
746
+ ctx.onSkip?.(entry, 'failed_primary');
747
+ continue;
748
+ }
749
+ const sentAs = fallbackEntrySentAs(entry);
750
+ if (isExcluded(entry.model) || isExcluded(sentAs.model)) {
751
+ ctx.onSkip?.(entry, 'excluded_model');
752
+ continue;
753
+ }
754
+ const catalogEntry = sentAs.model !== undefined ? findCatalogTextModel(sentAs.model, sentAs.provider) : undefined;
755
+ if (catalogEntry && required.some((capability) => !catalogEntryHasCapability(catalogEntry, capability))) {
756
+ ctx.onSkip?.(entry, 'missing_capability');
757
+ continue;
758
+ }
759
+ if (entry.origin === 'policy-default' && entry.group === 'uncensored') {
760
+ resolved.push({ ...entry, walkRole: uncensoredLegs < 2 ? 'standing' : 'refill' });
761
+ uncensoredLegs += 1;
762
+ }
763
+ else {
764
+ resolved.push(entry);
765
+ }
766
+ }
767
+ return resolved;
768
+ }
769
+ /**
770
+ * Whether the walk runs `entry` now: a refill only while a standing leg is
771
+ * owed one, and none of the policy chain's Claude legs after a refusal.
696
772
  *
697
- * @param tier - Caller's intended content tier. Mature/private-adult
698
- * triggers the uncensored prefix; safe/standard returns the
699
- * availability-only chain.
700
- * @param excludeProvider - Provider to omit (typically the primary
701
- * that already failed). Mirrors {@link buildFallbackChain}.
702
- * @returns Ordered fallback entries: uncensored prefix (when tier
703
- * warrants it) + availability suffix.
773
+ * @internal
774
+ */
775
+ export function gateFallbackEntry(state, entry) {
776
+ if (state.refusalSeen && entry.origin === 'policy-default' && isClaudeEntry(entry)) {
777
+ return { run: false, reason: 'claude_after_refusal' };
778
+ }
779
+ if (entry.walkRole === 'refill') {
780
+ if (state.refillsOwed < 1)
781
+ return { run: false, reason: 'refill_not_owed' };
782
+ return { run: true, state: { ...state, refillsOwed: state.refillsOwed - 1 } };
783
+ }
784
+ return { run: true, state };
785
+ }
786
+ /**
787
+ * The walk's state after an attempt failed with `error`: a standing leg that
788
+ * failed for anything but a content decline (a timeout, an open breaker and
789
+ * the context check's refusal included) is owed a refill, and a model
790
+ * refusal (code `content_filter`; a filter's `content_policy_violation`
791
+ * does not count) is recorded for the rest of the walk.
792
+ *
793
+ * @internal
794
+ */
795
+ export function advanceFallbackWalk(state, role, error) {
796
+ const owed = role === 'standing' && !isContentPolicyRefusal(error) ? 1 : 0;
797
+ const refusal = error?.code === 'content_filter';
798
+ return {
799
+ refillsOwed: state.refillsOwed + owed,
800
+ refusalSeen: state.refusalSeen || refusal,
801
+ };
802
+ }
803
+ /** The catalog the policy chain reads its ladders from. */
804
+ const POLICY_CATALOG = createUncensoredModelCatalog();
805
+ /** A catalog model never used as a fallback leg: too weak for a rescue reply. */
806
+ const NEVER_A_LEG_MODEL = 'meta-llama/llama-3.1-8b-instruct';
807
+ /**
808
+ * Build a policy-tier-aware fallback chain, for callers that pass
809
+ * `policyTier: 'mature' | 'private-adult'`: a refusal or an outage of the
810
+ * primary walks to an uncensored model before the availability legs.
811
+ *
812
+ * Mature and private-adult: the tier's catalog ladder
813
+ * (the catalog's `getFallbackLadder`; private-adult keeps the
814
+ * models that permit `erotic`) as OpenRouter legs in the `uncensored`
815
+ * group, llama-3.1-8b left out, then the {@link buildFallbackChain} suffix.
816
+ * Every entry is tagged `origin: 'policy-default'`, which lets the walk drop
817
+ * the leg naming the failed first model, keep two standing uncensored legs,
818
+ * and pass over Claude legs after a refusal. Safe, standard and an absent
819
+ * tier get {@link buildFallbackChain} unchanged and untagged.
820
+ *
821
+ * Each leg pins `cache: false`. A missing key drops its legs instead of
822
+ * throwing, so a partial deploy still gets a shorter usable chain.
823
+ *
824
+ * @param tier - The call's content tier.
825
+ * @param excludeProvider - Provider to leave out of the availability suffix
826
+ * (typically the failed primary's). The ladder legs ignore it: another
827
+ * model on the same provider is a valid rescue, and the walk drops the
828
+ * exact failed model.
704
829
  */
705
830
  export function buildPolicyAwareFallbackChain(tier, excludeProvider) {
706
- const isMatureTier = tier === 'mature' || tier === 'private-adult';
707
- if (!isMatureTier) {
831
+ if (tier !== 'mature' && tier !== 'private-adult') {
708
832
  return buildFallbackChain(excludeProvider);
709
833
  }
710
834
  const chain = [];
711
- // Hermes 3 405B leads: uncensored, large, instruction-following
712
- // proven on the wilds-ai identity-generation path. Skipped when
713
- // OPENROUTER_API_KEY is absent rather than throwing; the suffix
714
- // chain may still produce a usable fallback.
715
835
  if (process.env.OPENROUTER_API_KEY) {
716
- chain.push({
717
- provider: 'openrouter',
718
- model: 'nousresearch/hermes-3-llama-3.1-405b',
719
- cache: false,
720
- });
721
- // Sonnet via OpenRouter as the second uncensored band. We keep
722
- // it on OpenRouter (not direct Anthropic) because the chain's
723
- // `excludeProvider` semantics treat each entry as a provider
724
- // ID: using `anthropic` here would lock out the suffix's
725
- // Anthropic fallback. OpenRouter routes Claude under its own
726
- // billing surface, so the slot is independent.
727
- chain.push({
728
- provider: 'openrouter',
729
- model: 'anthropic/claude-sonnet-4',
730
- cache: false,
731
- });
836
+ const ladder = tier === 'private-adult'
837
+ ? POLICY_CATALOG.getFallbackLadder('private-adult', { contentIntent: 'erotic' })
838
+ : POLICY_CATALOG.getFallbackLadder('mature');
839
+ for (const entry of ladder) {
840
+ if (entry.modelId === NEVER_A_LEG_MODEL)
841
+ continue;
842
+ chain.push({
843
+ provider: entry.providerId,
844
+ model: entry.modelId,
845
+ cache: false,
846
+ origin: 'policy-default',
847
+ group: 'uncensored',
848
+ });
849
+ }
732
850
  }
733
- // Append the standard availability chain. Filter out duplicates
734
- // since the uncensored prefix may have already added openrouter.
735
- const availability = buildFallbackChain(excludeProvider);
736
- for (const entry of availability) {
737
- const alreadyInChain = chain.some((existing) => existing.provider === entry.provider && existing.model === entry.model);
738
- if (!alreadyInChain)
739
- chain.push(entry);
851
+ for (const entry of buildFallbackChain(excludeProvider)) {
852
+ const listed = chain.some((e) => e.provider === entry.provider && e.model === entry.model);
853
+ if (!listed)
854
+ chain.push({ ...entry, origin: 'policy-default' });
740
855
  }
741
856
  return chain;
742
857
  }
@@ -947,6 +1062,28 @@ export async function generateText(opts) {
947
1062
  function: { name: t.name, description: t.description, parameters: t.inputSchema },
948
1063
  }))
949
1064
  : undefined;
1065
+ // A catalog model is sent only a request it can hold: otherwise the
1066
+ // call throws in place of the send and the walk moves on. Checked on
1067
+ // the first native send and on the prompt shim's first send.
1068
+ const assertFitsContextWindow = (sent) => {
1069
+ const fit = checkContextFit({
1070
+ provider: resolved.providerId,
1071
+ model: resolved.modelId,
1072
+ messages: sent.messages,
1073
+ tools: sent.tools,
1074
+ maxTokens: opts.maxTokens,
1075
+ customModelParams: opts.customModelParams,
1076
+ });
1077
+ if (!fit.fits && fit.contextWindow !== undefined) {
1078
+ throw new ContextWindowExceededError({
1079
+ provider: resolved.providerId,
1080
+ model: resolved.modelId,
1081
+ contextWindow: fit.contextWindow,
1082
+ estimatedInputTokens: fit.estimatedInputTokens,
1083
+ outputTokens: fit.outputTokens,
1084
+ });
1085
+ }
1086
+ };
950
1087
  const allToolCalls = [];
951
1088
  const totalUsage = attemptUsage;
952
1089
  // Provider-reported model id of the final step (spec batch-1 C1);
@@ -1003,6 +1140,7 @@ export async function generateText(opts) {
1003
1140
  // provider's tool-unsupported error (see the catch after the loop).
1004
1141
  const toolMode = opts.toolMode ?? 'auto';
1005
1142
  const shimMaxRoundtrips = opts.maxSteps ?? 5;
1143
+ let shimSendChecked = false;
1006
1144
  const runShim = async () => {
1007
1145
  const loopResult = await runEmulatedToolLoop({
1008
1146
  tools: Array.from(toolMap.values()),
@@ -1018,6 +1156,12 @@ export async function generateText(opts) {
1018
1156
  messages: toShimMessages(messages),
1019
1157
  maxRoundtrips: shimMaxRoundtrips,
1020
1158
  callModel: async (msgs) => {
1159
+ // The shim sends rendered tool text in place of native schemas,
1160
+ // so its first send is checked on its own.
1161
+ if (!shimSendChecked) {
1162
+ shimSendChecked = true;
1163
+ assertFitsContextWindow({ messages: msgs });
1164
+ }
1021
1165
  const r = await provider.generateCompletion(resolved.modelId, msgs, {
1022
1166
  temperature: opts.temperature,
1023
1167
  ...(opts.topP !== undefined ? { topP: opts.topP } : {}),
@@ -1176,6 +1320,10 @@ export async function generateText(opts) {
1176
1320
  console.warn('[agentos] onBeforeGeneration hook error:', hookErr);
1177
1321
  }
1178
1322
  }
1323
+ // A continuation leg's first send carries its completed tool rounds.
1324
+ // Later steps are not checked again.
1325
+ if (step === 0)
1326
+ assertFitsContextWindow({ messages: effectiveMessages, tools: toolSchemas });
1179
1327
  const response = await withAgentOSSpan('agentos.api.generate_text.step', async (stepSpan) => {
1180
1328
  stepSpan?.setAttribute('llm.provider', resolved.providerId);
1181
1329
  stepSpan?.setAttribute('llm.model', resolved.modelId);
@@ -1668,40 +1816,76 @@ export async function generateText(opts) {
1668
1816
  { provider: primaryProviderId ?? 'unknown', model: primaryModelId, ok: false },
1669
1817
  ];
1670
1818
  // ── Fallback chain ────────────────────────────────────────────────
1671
- // Resolve fallback chain: caller-supplied wins, undefined triggers
1672
- // auto-build from env keys, empty array explicitly opts out.
1673
- // When `policyTier` is mature/private-adult, the auto-build picks
1674
- // the policy-aware chain (Hermes 3 + Sonnet uncensored prefix +
1675
- // standard availability suffix) instead of the availability-only
1676
- // chain. Caller-supplied chains are respected verbatim regardless
1677
- // of tier: explicit beats implicit.
1678
- const effectiveFallbacks = opts.fallbackProviders === undefined
1679
- ? buildPolicyAwareFallbackChain(opts.policyTier, metricProviderId)
1819
+ // Caller-supplied wins, undefined auto-builds from env keys for the
1820
+ // call's resolved tier (the policy-aware chain on mature and
1821
+ // private-adult), an empty array opts out. The top-level walk resolves
1822
+ // the chain once: it drops the policy chain's entry for the failed first
1823
+ // model, applies the call's explicit capability and exclusion
1824
+ // requirements, and marks the uncensored group's standing legs and
1825
+ // refills. A leg receives its resolved slice and never resolves it again.
1826
+ const walkTier = resolvePolicyTier(opts);
1827
+ const chainEntries = opts.fallbackProviders === undefined
1828
+ ? buildPolicyAwareFallbackChain(walkTier, metricProviderId)
1680
1829
  : opts.fallbackProviders;
1830
+ const logLegSkip = (entry, reason) => fallbackLogger.info('provider fallback skipped', {
1831
+ event: 'fallback_leg_skipped',
1832
+ api: 'generateText',
1833
+ reason,
1834
+ primaryProvider: metricProviderId,
1835
+ fallbackProvider: entry.provider,
1836
+ fallbackModel: entry.model,
1837
+ });
1838
+ // Resolved only when the walk runs, so its skip lines describe a walk.
1681
1839
  // A call whose prompt-shim tools already ran cannot be continued, and a
1682
1840
  // restart would run them again; it surfaces the error instead.
1683
- if (effectiveFallbacks.length &&
1684
- isRetryableError(error) &&
1685
- !toolProgress.shimRanTool) {
1841
+ const walks = isRetryableError(error) && !toolProgress.shimRanTool;
1842
+ const effectiveFallbacks = !walks
1843
+ ? []
1844
+ : opts.__fallbackWalk
1845
+ ? chainEntries
1846
+ : resolveFallbackChain(chainEntries, {
1847
+ primary: { provider: metricProviderId, model: metricModelId },
1848
+ requiredCapabilities: explicitRequiredCapabilities(opts),
1849
+ excludedModelIds: opts.routerParams?.excludedModelIds,
1850
+ onSkip: logLegSkip,
1851
+ });
1852
+ // This attempt's own failure updates the walk: a refusal is recorded,
1853
+ // and a standing leg that failed on availability is owed a refill.
1854
+ let walkState = advanceFallbackWalk(opts.__fallbackWalk?.state ?? INITIAL_FALLBACK_WALK, opts.__fallbackWalk?.role, error);
1855
+ if (walks && effectiveFallbacks.length) {
1686
1856
  let lastError = error;
1687
1857
  let attempt = 0;
1688
1858
  for (const fb of effectiveFallbacks) {
1689
1859
  attempt += 1;
1860
+ // A refill runs only while a standing leg is owed one, and the
1861
+ // policy chain's Claude legs are passed over after a refusal.
1862
+ const gate = gateFallbackEntry(walkState, fb);
1863
+ if (!gate.run) {
1864
+ logLegSkip(fb, gate.reason);
1865
+ continue;
1866
+ }
1867
+ walkState = gate.state;
1690
1868
  // Skip fallback entries whose breaker is already open. Without
1691
1869
  // this check, the loop would still spend a full network round-
1692
1870
  // trip on every dead fallback in the chain before reaching the
1693
1871
  // next healthy one. The recursive `generateText` call below would
1694
1872
  // also short-circuit at the same isOpen() check, but the outer
1695
1873
  // skip avoids the recursion overhead + the extra log line.
1696
- if (globalLLMProviderHealth.isOpen(fb.provider)) {
1874
+ // The breaker read is the provider the leg is sent to: an
1875
+ // `openrouter:` id under another provider goes to OpenRouter.
1876
+ const legProvider = fallbackEntrySentAs(fb).provider;
1877
+ if (globalLLMProviderHealth.isOpen(legProvider)) {
1697
1878
  fallbackLogger.info('provider fallback skipped (circuit open)', {
1698
1879
  event: 'fallback_skipped_circuit_open',
1699
1880
  api: 'generateText',
1700
1881
  primaryProvider: metricProviderId,
1701
- fallbackProvider: fb.provider,
1882
+ fallbackProvider: legProvider,
1702
1883
  fallbackModel: fb.model,
1703
1884
  attempt,
1704
1885
  });
1886
+ // An open breaker is an availability failure: a standing leg is
1887
+ // owed a refill.
1888
+ walkState = advanceFallbackWalk(walkState, fb.walkRole, undefined);
1705
1889
  continue;
1706
1890
  }
1707
1891
  try {
@@ -1735,6 +1919,12 @@ export async function generateText(opts) {
1735
1919
  ...opts,
1736
1920
  provider: fb.provider,
1737
1921
  model: fb.model,
1922
+ // Legs run as named: no router re-picks the model, and the call's
1923
+ // resolved tier travels with them.
1924
+ router: undefined,
1925
+ policyTier: walkTier,
1926
+ // The walk's state before this leg, and its role.
1927
+ __fallbackWalk: { state: walkState, role: fb.walkRole },
1738
1928
  // When a builder exists, ALWAYS override _responseFormat —
1739
1929
  // including with an explicit `undefined` on builder failure or
1740
1930
  // decline. Merely omitting the key would let the `...opts`
@@ -1764,7 +1954,7 @@ export async function generateText(opts) {
1764
1954
  // fallback provider rather than the primary's overrides.
1765
1955
  apiKey: undefined,
1766
1956
  baseUrl: undefined,
1767
- // Preserve the REMAINING explicit chain (entries AFTER the current
1957
+ // Preserve the REMAINING resolved chain (entries AFTER the current
1768
1958
  // fb; `attempt` is 1-indexed so slice(attempt) drops fb and all
1769
1959
  // already-tried entries). This stops the recursion from rebuilding
1770
1960
  // the default cheap chain (which includes gpt-4o-mini) when a
@@ -1844,6 +2034,7 @@ export async function generateText(opts) {
1844
2034
  // The leg walked every entry after it and all of them failed.
1845
2035
  if (chainWalkedBefore(fbError))
1846
2036
  break;
2037
+ walkState = advanceFallbackWalk(walkState, fb.walkRole, fbError);
1847
2038
  }
1848
2039
  }
1849
2040
  // All fallbacks exhausted: fall through to throw