diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 7d61b2dbd0..ab690bf6a6 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -86,9 +86,17 @@ write. External provider managers and user-owned root routing also remain author For a spawned worker, opencodex builds this priority order: 1. The requested primary model. -2. The role's `model_fallback` list from its `$CODEX_HOME/agents/*.toml` definition. +2. A per-model chain from `subagentModelFallbackByModel` in opencodex config, keyed by + the requested primary model. 3. The global `subagentModelFallback` list in opencodex config. +Per-role fallback chains belong in opencodex config, not in +`$CODEX_HOME/agents/*.toml`. Codex 0.146+ strictly deserializes agent role files and +rejects `model_fallback` as an unknown field, which skips the entire role definition +(#1190). opencodex can still read a legacy `model_fallback` line from the TOML for +backwards compatibility, but `ocx doctor` warns about it and Codex itself will ignore +the affected role. + Duplicate model ids are removed while preserving the first occurrence. During selection, opencodex skips candidates that are disabled, unroutable, backed by a disabled provider, marked unhealthy, inside a cooldown, missing a usable pooled Codex account, or beyond the configured quota threshold. diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index 611d75f6cc..daa1c2b839 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -65,9 +65,11 @@ v1 では、opencodex は、`max` または `ultra` の取り組みでアップ 生成されたワーカーの場合、opencodex は次の優先順位を構築します。 1. 要求されたプライマリ モデル。 -2. `$CODEX_HOME/agents/*.toml` 定義からのロールの `model_fallback` リスト。 +2. opencodex 構成内の `subagentModelFallbackByModel` によるモデル単位のチェーン(要求されたプライマリ モデルがキー)。 3. opencodex 構成内のグローバル `subagentModelFallback` リスト。 +ロール単位のフォールバックチェーンは、`$CODEX_HOME/agents/*.toml` ではなく opencodex 構成に置く必要があります。Codex 0.146+ はエージェントロールファイルを厳密に逆シリアル化し、`model_fallback` を未知フィールドとして拒否するため、ロール定義全体がスキップされます(#1190)。opencodex は後方互換性のために TOML 内のレガシー `model_fallback` 行を引き続き読み取れますが、`ocx doctor` が警告を出し、Codex 自体は影響を受けるロールを無視します。 + 重複するモデル ID は、最初に出現したモデル ID を保持しながら削除されます。選択中、opencodex は、無効になっている、ルーティングできない、無効なプロバイダーによってサポートされている、異常とマークされている、クールダウン中、使用可能なプールされた Codex アカウントがない、または設定されたクォータしきい値を超えている候補をスキップします。可用性プローブは `subagentModelFallbackPollMs` に対してキャッシュされます (デフォルトでは 60 秒)。 フォールバックでは、互換性のない暗号化タスクは読み取り可能になりません。子タスクが ChatGPT 用に暗号化されている場合、外部モデルがチェーンの前の方に表示されている場合でも、選択は正規のネイティブ ChatGPT ターゲットに制限されます。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/agents.md b/docs-site/src/content/docs/ja/reference/configuration/agents.md index 74d90dc9b7..03b86332ef 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -17,6 +17,7 @@ description: マルチエージェント サーフェス、委任ガイダンス | `multiAgentGuidanceEnabled?` | `boolean` | `true` | opencodex が作成した v1/v2 開発者ガイダンスのみを制御します。ネイティブ エージェントのデフォルト、ツール、ルーティング、ロスター、またはエフォート キャップは変更されません。 | | `syncCodexSubagentDefaults?` | `boolean` | `false` |同期/再起動中に、Codex のネイティブ デフォルトとして `injectionModel` およびオプションの `injectionEffort` を書き込むようにオプトインします。 `injectionModel`が必要です。 | | `subagentModelFallback?` | `string[]` | `[]` |生成された子ターンの優先順位付きグローバル フォールバック モデル。 | +| `subagentModelFallbackByModel?` | `Record` | `{}` | 要求されたプライマリ モデル id をキーとするモデル単位のフォールバックチェーン。ロール単位のフォールバックメタデータの推奨場所です。Codex の agent TOML に `model_fallback` を書くと Codex 0.146+ がロールをスキップします(#1190)。 | | `subagentModelFallbackPollMs?` | `number` | `60000` |可用性プローブのキャッシュ間隔。 1000 ミリ秒未満の値はデフォルトに戻ります。 | | `effortCap?` | `string` | — | v2 のメイン ターンとマークされた子ターンの条件を満たすためのハード シーリング。 `low` ~ `ultra` を受け入れます。 | | `subagentEffortCap?` | `string` | — |スポーンされた子のターンのみの追加の上限。両方の上限が適用される場合は、低い方が優先されます。 | @@ -44,9 +45,14 @@ V1 ガイダンスは、`max` または `ultra` でのみプロアクティブ 生成された子のフォールバック順序は次のとおりです。 1. 要求されたプライマリ モデル。 -2. ロールレベル `model_fallback` から `$CODEX_HOME/agents/*.toml`;それから +2. `subagentModelFallbackByModel` によるモデル単位のチェーン(プライマリ モデルがキー);それから 3. グローバル `subagentModelFallback` エントリ。 +ロール単位のフォールバックチェーンは opencodex 構成に置く必要があります。`model_fallback` を +`$CODEX_HOME/agents/*.toml` に書くと、Codex 0.146+ が未知フィールドとしてロールファイル全体を +拒否し、ロールをスキップします(#1190)。TOML 内のレガシー `model_fallback` 行は後方互換性の +ために引き続き読み取られますが、`ocx doctor` がそれをフラグ付けします。 + opencodex は、無効、ルーティング不能、異常、冷却期間、またはクォータしきい値の候補をスキップします。可用性スナップショットは `subagentModelFallbackPollMs` に対してキャッシュされます。暗号化された子タスクは、チェーンを正規のネイティブ ChatGPT ターゲットに制限できます。暗号化されたペイロードを読み取ることができる人がいない場合、読み取り不可能な暗号文が別の場所にルーティングされる代わりに、リクエストは失敗します。 ```json @@ -57,6 +63,9 @@ opencodex は、無効、ルーティング不能、異常、冷却期間、ま "injectionEffort": "high", "syncCodexSubagentDefaults": true, "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallbackByModel": { + "gpt-5.5": ["gpt-5.4-mini"] + }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" } diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index 9678ee0ca0..cd987ed761 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -65,9 +65,11 @@ v1에서는 opencodex가 `max` 또는 `ultra` 추론 강도에서만 업스트 스폰된 작업자에 대해 opencodex는 다음 우선순위를 적용합니다. 1. 요청한 기본 모델 -2. 역할의 `$CODEX_HOME/agents/*.toml` 정의에 있는 `model_fallback` 목록 +2. opencodex 설정의 `subagentModelFallbackByModel`에 있는 모델별 체인 (요청한 기본 모델이 키) 3. opencodex 설정의 전역 `subagentModelFallback` 목록 +역할별 폴백 체인은 `$CODEX_HOME/agents/*.toml`이 아니라 opencodex 설정에 두어야 합니다. Codex 0.146+는 에이전트 역할 파일을 엄격하게 역직렬화하며 `model_fallback`을 알 수 없는 필드로 거부해 역할 정의 전체를 건너뜁니다 (#1190). opencodex는 하위 호환성을 위해 TOML의 기존 `model_fallback` 줄을 계속 읽을 수 있지만, `ocx doctor`가 경고하며 Codex 자체는 해당 역할을 무시합니다. + 중복 모델 id는 첫 번째 출현을 유지한 채 제거합니다. 선택 과정에서 opencodex는 비활성화된 후보, 라우팅 불가 후보, 비활성화된 프로바이더가 받쳐주는 후보, unhealthy로 표시된 후보, cooldown 중인 후보, 사용할 수 있는 pooled Codex 계정이 없는 후보, 또는 설정된 quota 임계치를 넘는 후보를 건너뜁니다. 가용성 프로브는 기본값 60초인 `subagentModelFallbackPollMs` 동안 캐시됩니다. 폴백이 호환되지 않는 암호화 작업을 읽을 수 있게 만들어 주지는 않습니다. 자식 작업이 ChatGPT용으로 암호화되어 있으면, 체인 앞쪽에 외부 모델이 있더라도 선택은 정규 네이티브 ChatGPT 대상만 허용됩니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index 579386afe4..47589fb3cc 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -17,6 +17,7 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 | `multiAgentGuidanceEnabled?` | `boolean` | `true` | opencodex가 작성하는 v1/v2 개발자 안내만 제어합니다. 네이티브 에이전트 기본값, 도구, 라우팅, 로스터, 노력 상한은 바꾸지 않습니다. | | `syncCodexSubagentDefaults?` | `boolean` | `false` | 동기화 또는 재시작 시 `injectionModel`과 선택적 `injectionEffort`를 Codex의 네이티브 기본값으로 기록하도록 선택합니다. `injectionModel`이 필요합니다. | | `subagentModelFallback?` | `string[]` | `[]` | 생성된 하위 턴에 적용되는 전역 대체 모델 우선순위 목록입니다. | +| `subagentModelFallbackByModel?` | `Record` | `{}` | 요청한 기본 모델 id를 키로 하는 모델별 대체 체인입니다. 역할별 대체 메타데이터의 지원 위치입니다. Codex agent TOML에 `model_fallback`을 쓰면 Codex 0.146+가 역할을 건너뜁니다 (#1190). | | `subagentModelFallbackPollMs?` | `number` | `60000` | 사용 가능성 검사 캐시 간격입니다. 1000 ms 미만의 값은 기본값으로 돌아갑니다. | | `effortCap?` | `string` | — | 자격을 갖춘 v2 메인 턴과 표시된 생성 하위 턴에 대한 하드 상한입니다. `low`부터 `ultra`까지 허용합니다. | | `subagentEffortCap?` | `string` | — | 생성된 하위 턴에만 적용되는 추가 상한입니다. 두 상한이 모두 적용되면 더 낮은 값이 이깁니다. | @@ -44,9 +45,14 @@ V1 안내는 `max` 또는 `ultra`에서만 선제 텍스트로 제공됩니다. 생성된 하위 작업의 대체 순서는 다음과 같습니다. 1. 요청된 기본 모델 -2. `$CODEX_HOME/agents/*.toml`의 역할 수준 `model_fallback` +2. `subagentModelFallbackByModel`의 모델별 체인 (기본 모델이 키) 3. 전역 `subagentModelFallback` 항목 +역할별 폴백 체인은 opencodex 설정에 있어야 합니다. `model_fallback`을 +`$CODEX_HOME/agents/*.toml`에 쓰면 Codex 0.146+가 알 수 없는 필드로 역할 파일 전체를 +거부하고 역할을 건너뜁니다 (#1190). TOML의 기존 `model_fallback` 줄은 하위 호환성을 위해 +계속 읽히지만 `ocx doctor`가 이를 표시합니다. + opencodex는 비활성, 라우팅 불가, 비정상, 쿨다운 중, 또는 할당량 임계값에 걸린 후보를 건너뜁니다. 사용 가능성 스냅샷은 `subagentModelFallbackPollMs` 동안 캐시됩니다. 암호화된 하위 작업은 체인을 정규 네이티브 ChatGPT 대상으로만 제한할 수 있습니다. 어떤 대상도 암호화된 페이로드를 읽을 수 없으면, 읽을 수 없는 암호문을 다른 곳으로 라우팅하는 대신 요청이 실패합니다. ```json @@ -57,6 +63,9 @@ opencodex는 비활성, 라우팅 불가, 비정상, 쿨다운 중, 또는 할 "injectionEffort": "high", "syncCodexSubagentDefaults": true, "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallbackByModel": { + "gpt-5.5": ["gpt-5.4-mini"] + }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" } diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 948c9350a0..f490626d2a 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -18,6 +18,7 @@ routes, and limits delegated work. | `multiAgentGuidanceEnabled?` | `boolean` | `true` | Controls only opencodex-authored v1/v2 developer guidance; it does not change native agent defaults, tools, routing, rosters, or effort caps. | | `syncCodexSubagentDefaults?` | `boolean` | `false` | Opt into writing `injectionModel` and optional `injectionEffort` as Codex's native defaults during sync/restart. Requires `injectionModel`. | | `subagentModelFallback?` | `string[]` | `[]` | Priority-ordered global fallback models for spawned child turns. | +| `subagentModelFallbackByModel?` | `Record` | `{}` | Per-primary-model fallback chains, keyed by the requested primary model id. This is the supported home for per-role fallback metadata; `model_fallback` inside Codex agent TOML makes Codex 0.146+ skip the role (#1190). | | `subagentModelFallbackPollMs?` | `number` | `60000` | Availability-probe cache interval. Values below 1000 ms fall back to the default. | | `effortCap?` | `string` | — | Hard ceiling for qualifying v2 main turns and marked spawned-child turns. Accepts `low` through `ultra`. | | `subagentEffortCap?` | `string` | — | Additional ceiling for spawned-child turns only. When both caps apply, the lower wins. | @@ -73,9 +74,14 @@ created Codex tasks and do not cause delegation by themselves. Spawned-child fallback order is: 1. the requested primary model; -2. role-level `model_fallback` from `$CODEX_HOME/agents/*.toml`; then +2. per-model chains from `subagentModelFallbackByModel` (keyed by the primary model); then 3. global `subagentModelFallback` entries. +Per-role fallback chains must live in opencodex config. Writing `model_fallback` into +`$CODEX_HOME/agents/*.toml` makes Codex 0.146+ reject the whole role file as an unknown +field and skip the role (#1190). A legacy `model_fallback` line in the TOML is still +read for backwards compatibility, but `ocx doctor` flags it. + opencodex skips disabled, unroutable, unhealthy, cooling-down, or quota-threshold candidates. The availability snapshot is cached for `subagentModelFallbackPollMs`. Encrypted child tasks can restrict the chain to canonical native ChatGPT targets; if none can read the encrypted payload, the request @@ -89,6 +95,9 @@ fails instead of routing unreadable ciphertext elsewhere. "injectionEffort": "high", "syncCodexSubagentDefaults": true, "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallbackByModel": { + "gpt-5.5": ["gpt-5.4-mini"] + }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" } diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index 74cc0041ff..416c6d9d02 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -95,9 +95,15 @@ opencodex владеет активной маршрутизацией Codex, sy Для порождённого воркера opencodex строит такой порядок приоритета: 1. Запрошенная основная модель. -2. Список `model_fallback` роли из её определения `$CODEX_HOME/agents/*.toml`. +2. Модельная цепочка из `subagentModelFallbackByModel` в конфигурации opencodex, ключ — запрошенная основная модель. 3. Глобальный список `subagentModelFallback` в конфигурации opencodex. +Модельные цепочки fallback для ролей должны храниться в конфигурации opencodex, а не в +`$CODEX_HOME/agents/*.toml`. Codex 0.146+ строго десериализует файлы ролей агента и отвергает +`model_fallback` как неизвестное поле, из-за чего пропускается всё определение роли (#1190). +opencodex по-прежнему читает устаревшую строку `model_fallback` из TOML для обратной +совместимости, но `ocx doctor` предупреждает о ней, а сам Codex игнорирует затронутую роль. + Дубликаты id моделей удаляются с сохранением первого вхождения. При выборе opencodex пропускает кандидатов, которые отключены, немаршрутизируемы, опираются на отключённого провайдера, помечены как unhealthy, находятся в cooldown, не имеют доступного pooled-аккаунта Codex или вышли за diff --git a/docs-site/src/content/docs/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index 062750e2a4..4febe3254f 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -18,6 +18,7 @@ description: Multi-agent surface, guidance при делегировании, pr | `multiAgentGuidanceEnabled?` | `boolean` | `true` | Управляет только developer-guidance, написанным самим opencodex, для v1/v2; не меняет native default'ы агентов, tools, routing, roster'ы и effort cap'ы. | | `syncCodexSubagentDefaults?` | `boolean` | `false` | Разрешает записывать `injectionModel` и, при наличии, `injectionEffort` как native default'ы Codex при sync/restart. Требует `injectionModel`. | | `subagentModelFallback?` | `string[]` | `[]` | Глобальные fallback-модели для порождённых child-turn'ов в порядке приоритета. | +| `subagentModelFallbackByModel?` | `Record` | `{}` | Модельные цепочки fallback по ключу запрошенной основной модели. Это поддерживаемое место для per-role метаданных fallback; поле `model_fallback` в `$CODEX_HOME/agents/*.toml` поддерживается только как legacy и заставляет Codex 0.146+ пропустить роль (#1190). | | `subagentModelFallbackPollMs?` | `number` | `60000` | Интервал кэша для availability probe. Значения ниже 1000 ms возвращаются к дефолту. | | `effortCap?` | `string` | — | Жёсткий потолок effort для qualifying v2 main-turn'ов и помеченных spawned-child turn'ов. Принимает `low`–`ultra`. | | `subagentEffortCap?` | `string` | — | Дополнительный потолок только для spawned-child turn'ов. Если применимы оба cap'а, выигрывает более низкий. | @@ -69,9 +70,14 @@ user-owned target field'ы считаются конфликтом и сохра Порядок fallback для spawned-child такой: 1. запрошенная основная модель; -2. role-level `model_fallback` из `$CODEX_HOME/agents/*.toml`; затем +2. модельные цепочки из `subagentModelFallbackByModel` (ключ — основная модель); затем 3. глобальные записи `subagentModelFallback`. +Модельные цепочки fallback для ролей должны храниться в конфигурации opencodex. Запись +`model_fallback` в `$CODEX_HOME/agents/*.toml` заставляет Codex 0.146+ отклонить весь файл +роли как неизвестное поле и пропустить роль (#1190). Устаревшая строка `model_fallback` в TOML +по-прежнему читается для обратной совместимости, но `ocx doctor` помечает её. + opencodex пропускает кандидатов, которые отключены, не маршрутизируются, unhealthy, находятся в cooldown либо уже достигли порога quota. Availability-снимок кэшируется на `subagentModelFallbackPollMs`. Шифрованные child-task'и могут ограничить цепочку каноническими @@ -86,6 +92,9 @@ native ChatGPT-target'ами; если ни одна из них не может "injectionEffort": "high", "syncCodexSubagentDefaults": true, "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallbackByModel": { + "gpt-5.5": ["gpt-5.4-mini"] + }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" } diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index bec2d70da4..2ac9637416 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -65,9 +65,11 @@ Dashboard 上的 **Sub-agent delegation** 控件管理三个相关设置: 对于生成出的工作器,opencodex 会按以下优先级构建顺序: 1. 请求的主模型。 -2. 该角色在其 `$CODEX_HOME/agents/*.toml` 定义中的 `model_fallback` 列表。 +2. opencodex 配置中 `subagentModelFallbackByModel` 提供的 per-model 链,按请求的主模型做键。 3. opencodex 配置中的全局 `subagentModelFallback` 列表。 +per-role fallback 链应该放在 opencodex 配置里,而不是 `$CODEX_HOME/agents/*.toml`。Codex 0.146+ 会严格反序列化 agent 角色文件,并把 `model_fallback` 当作未知字段拒绝,导致整个角色定义被跳过(#1190)。opencodex 为了向后兼容仍然能读取 TOML 里的旧版 `model_fallback`,但 `ocx doctor` 会给出警告,而且 Codex 本身会忽略受影响的角色。 + 重复的模型 id 会在保留第一次出现的前提下移除。在选择过程中,opencodex 会跳过已禁用、不可路由、由已禁用 provider 支撑、标记为 unhealthy、处于 cooldown、没有可用 pooled Codex 账户,或者超出配置配额阈值的候选项。可用性探测会缓存 `subagentModelFallbackPollMs` 的时长,默认 60 秒。 fallback 不会让不兼容的加密任务变得可读。当子任务为 ChatGPT 加密时,即使链中更靠前出现了外部模型,选择也只会限制在规范的原生 ChatGPT 目标上。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index 473c15d59e..0afa4603fe 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -17,6 +17,7 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 | `multiAgentGuidanceEnabled?` | `boolean` | `true` | 只控制 opencodex 生成的 v1/v2 开发者引导;不会改变原生代理默认值、工具、路由、名单或 effort 上限。 | | `syncCodexSubagentDefaults?` | `boolean` | `false` | 允许在同步或重启时,将 `injectionModel` 以及可选的 `injectionEffort` 写入为 Codex 的原生默认值。需要 `injectionModel`。 | | `subagentModelFallback?` | `string[]` | `[]` | 按优先级排序的全局回退模型,用于派生的子轮次。 | +| `subagentModelFallbackByModel?` | `Record` | `{}` | 按请求的主模型 id 做键的 per-model 回退链。这是 per-role fallback 元数据的受支持存放位置;`model_fallback` 写在 Codex agent TOML 里会让 Codex 0.146+ 跳过该角色(#1190)。 | | `subagentModelFallbackPollMs?` | `number` | `60000` | 可用性探测缓存间隔。低于 1000 ms 的值会回退到默认值。 | | `effortCap?` | `string` | — | 对符合条件的 v2 主轮次和标记的派生子轮次设置硬上限。接受 `low` 到 `ultra`。 | | `subagentEffortCap?` | `string` | — | 仅针对派生子轮次的额外上限。两个上限同时适用时,较低者生效。 | @@ -44,9 +45,13 @@ V1 引导只会在 `max` 或 `ultra` 时以主动文本形式出现。V2 只有 派生子轮次的回退顺序如下: 1. 请求的主模型; -2. 来自 `$CODEX_HOME/agents/*.toml` 的角色级 `model_fallback`;然后是 +2. `subagentModelFallbackByModel` 中的 per-model 链(按主模型做键);然后是 3. 全局 `subagentModelFallback` 条目。 +per-role fallback 链必须放在 opencodex 配置里。把 `model_fallback` 写进 +`$CODEX_HOME/agents/*.toml` 会让 Codex 0.146+ 把整个角色文件当作未知字段拒绝并跳过该角色 +(#1190)。TOML 中的旧版 `model_fallback` 仍会被读取以保持向后兼容,但 `ocx doctor` 会标记它。 + opencodex 会跳过已禁用、不可路由、不健康、处于冷却中,或已达到配额阈值的候选项。可用性快照会在 `subagentModelFallbackPollMs` 期间缓存。加密的子任务可以把链限制为规范的原生 ChatGPT 目标;如果没有任何目标能读取加密载荷,请求就会失败,而不是把不可读的密文路由到别处。 ```json @@ -57,6 +62,9 @@ opencodex 会跳过已禁用、不可路由、不健康、处于冷却中,或 "injectionEffort": "high", "syncCodexSubagentDefaults": true, "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallbackByModel": { + "gpt-5.5": ["gpt-5.4-mini"] + }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" } diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 29b8012a71..55054a6d0f 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -23,6 +23,7 @@ import { withNativeMainSharedClaim } from "../codex/native-main-claim"; import { probeNativeProfileRecoveryState, resolveNativeProfileContext } from "../codex/native-profile-store"; import { NativeProfileError } from "../codex/native-profile-types"; import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home"; +import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback"; import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim"; import { countPendingOpencodexHistory } from "../codex/history-provider"; import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings"; @@ -921,6 +922,15 @@ export async function runDoctor(args: string[] = []): Promise { } } + console.log("\nCodex agent role files"); + const tomlFallbackRoles = scanCodexAgentRolesWithTomlModelFallback(resolveCodexHomeDirImpl()); + if (tomlFallbackRoles.length === 0) { + console.log(" ok no per-role model_fallback fields in $CODEX_HOME/agents/*.toml"); + } else { + console.log(` [WARN] ${tomlFallbackRoles.length} agent role file${tomlFallbackRoles.length === 1 ? "" : "s"} contain${tomlFallbackRoles.length === 1 ? "s" : ""} \`model_fallback\`: ${tomlFallbackRoles.join(", ")}`); + console.log(" Codex >= 0.146 rejects that field as unknown and skips the whole role. Move the chains to opencodex config `subagentModelFallbackByModel` (keyed by primary model) and remove the field from the TOML files."); + } + const dual = collectWslDualInstall(); if (dual.wsl) { console.log("\nWSL Codex installs"); diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 0e51b423c0..db99df8b97 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -417,6 +417,37 @@ function subagentQuotaPrimeBlockedByHostCircuit(config: OcxConfig): boolean { return getUpstreamHostHealth(key)?.cooldownUntil !== undefined; } +/** + * Per-primary-model fallback chains from opencodex config (#1190). + * + * Storing `model_fallback` inside `$CODEX_HOME/agents/*.toml` makes Codex >= 0.146 + * reject the whole role file as an unknown field. The config-keyed map is the + * supported home for that metadata; keys match the requested primary model id, + * using the same raw/encoded slug tolerance as the TOML role lookup. + */ +export function resolveConfiguredModelFallbackForPrimary( + primary: string, + config: OcxConfig, +): string[] { + const byModel = config.subagentModelFallbackByModel; + if (!byModel || typeof byModel !== "object") return []; + const entries: string[] = []; + const seen = new Set(); + const push = (model: string) => { + const trimmed = model.trim(); + if (trimmed === "") return; + const key = fallbackChainKey(trimmed, config.codexAccountNamespaces); + if (seen.has(key)) return; + seen.add(key); + entries.push(trimmed); + }; + for (const [key, chain] of Object.entries(byModel)) { + if (!slugsEquivalent(key, primary)) continue; + for (const model of chain) push(model); + } + return entries; +} + /** * Best-effort quota refresh before subagent model selection. * Concurrent callers share one in-flight promise. The success TTL is updated only @@ -479,11 +510,17 @@ export function applySubagentModelFallback( accountUsabilityOptions?: CodexAccountUsabilityOptions, ): { from?: string; to?: string; skipped?: string[] } | null { if (!isThreadSpawnRequest(headers)) return null; - const roleFallback = resolveAgentModelFallbackForPrimary( + const tomlRoleFallback = resolveAgentModelFallbackForPrimary( parsed.modelId, getCodexHome(), config.codexAccountNamespaces, ); + // Config-keyed chains are the supported per-role home (#1190); TOML `model_fallback` + // stays readable for backwards compatibility with homes written before Codex 0.146. + const roleFallback = [ + ...resolveConfiguredModelFallbackForPrimary(parsed.modelId, config), + ...tomlRoleFallback, + ]; const globalFallback = config.subagentModelFallback ?? []; if (globalFallback.length === 0 && roleFallback.length === 0) return null; const selection = selectAvailableSubagentModel( @@ -510,33 +547,192 @@ export function subagentFallbackGuidanceText(config: OcxConfig): string { return ` Subagent model fallback chain (priority order): ${quoted}. When the primary model is quota-exhausted, opencodex rewrites thread_spawn requests to the next available model automatically.`; } -const TOML_STRING_ARRAY = /^(model_fallback)\s*=\s*\[(.*)\]\s*$/s; +const TOML_MODEL_FALLBACK_KEY = /^\s*(?:model_fallback|"model_fallback"|'model_fallback')\s*=/; + +type TomlModelFallbackField = { present: false; value: null } | { present: true; value: string[] | null }; + +type TomlScanState = { + inMultilineString: '"""' | "'''" | null; + arrayDepth: number; +}; + +/** Track TOML strings, comments, and array brackets on one line. */ +function scanTomlLine(line: string, state: TomlScanState): void { + let i = 0; + while (i < line.length) { + const ch = line[i]!; + if (ch === "#") return; + if (ch === '"' || ch === "'") { + const delimiter = ch.repeat(3); + if (line.startsWith(delimiter, i)) { + const end = findTomlMultilineStringEnd(line, i + 3, ch); + if (end === -1) { + state.inMultilineString = delimiter as '"""' | "'''"; + return; + } + i = end + 3; + continue; + } + i++; + while (i < line.length) { + if (ch === '"' && line[i] === "\\") { + i += 2; + continue; + } + if (line[i] === ch) break; + i++; + } + i++; + continue; + } + if (ch === "[") state.arrayDepth++; + else if (ch === "]") state.arrayDepth = Math.max(0, state.arrayDepth - 1); + i++; + } +} + +/** Parse a TOML string starting at `start`; returns the decoded value and end offset. */ +function parseTomlStringAt(text: string, start: number): { value: string; end: number } | null { + const quote = text[start]!; + const delimiter = quote.repeat(3); + if (text.startsWith(delimiter, start)) { + const end = findTomlMultilineStringEnd(text, start + 3, quote); + if (end === -1) return null; + let value = text.slice(start + 3, end).trim(); + if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + return { value, end: end + 3 }; + } + let i = start + 1; + let value = ""; + while (i < text.length) { + const ch = text[i]!; + if (quote === '"' && ch === "\\") { + const next = text[i + 1]; + if (next === '"' || next === "\\") { + value += next; + i += 2; + continue; + } + } + if (ch === quote) return { value, end: i + 1 }; + value += ch; + i++; + } + return null; +} + +function findTomlMultilineStringEnd(text: string, from: number, quote: string): number { + const delimiter = quote.repeat(3); + let index = text.indexOf(delimiter, from); + while (index !== -1) { + if (quote === "'") return index; + let backslashes = 0; + for (let j = index - 1; j >= 0 && text[j] === "\\"; j--) backslashes += 1; + if (backslashes % 2 === 0) return index; + index = text.indexOf(delimiter, index + 1); + } + return -1; +} -function parseTomlStringArray(raw: string): string[] { - const matches = [...raw.matchAll(/"((?:\\.|[^"\\])*)"/g)]; - return matches.map(match => match[1]!.replace(/\\"/g, "\"")); +/** After an array close, only horizontal whitespace, an inline comment, or the line end is valid. */ +function isValidTomlArrayTail(text: string, from: number): boolean { + let i = from; + while (i < text.length && (text[i] === " " || text[i] === "\t")) i += 1; + if (i >= text.length || text[i] === "\n" || text[i] === "\r") return true; + if (text[i] === "#") { + while (i < text.length && text[i] !== "\n") i += 1; + return true; + } + return false; +} + +/** Parse a TOML string-array value; null when the value is not a well-formed array of strings. */ +function parseTomlStringArrayValue(text: string): string[] | null { + const values: string[] = []; + let i = 0; + const skipIgnored = () => { + while (i < text.length) { + const ch = text[i]!; + if (ch === "#") { + while (i < text.length && text[i] !== "\n") i += 1; + continue; + } + if (/\s/.test(ch)) { + i += 1; + continue; + } + break; + } + }; + skipIgnored(); + if (text[i] !== "[") return null; + i += 1; + let expectValue = true; + for (;;) { + skipIgnored(); + if (i >= text.length) return null; + const ch = text[i]!; + if (ch === "]") { + if (!isValidTomlArrayTail(text, i + 1)) return null; + return values; + } + if (ch === ",") { + if (expectValue) return null; // leading or doubled comma + expectValue = true; + i += 1; + continue; + } + if (ch === '"' || ch === "'") { + if (!expectValue) return null; // adjacent strings must be comma-separated + const parsed = parseTomlStringAt(text, i); + if (!parsed) return null; + values.push(parsed.value); + i = parsed.end; + expectValue = false; + continue; + } + return null; + } } -function parseTomlModelFallback(content: string): string[] | null { - const match = content.match(/^\s*model_fallback\s*=\s*\[(.*?)\]\s*$/ms); - if (!match) return null; - return parseTomlStringArray(match[1] ?? ""); +/** + * TOML-aware, presence-aware parse of the `model_fallback` field. Quoted keys + * are recognized, and text inside strings (including multiline strings) is + * never treated as a key. `present` is true whenever the key exists, even when + * the value is not a readable string array; `value` is null in that case. + */ +function parseTomlModelFallbackField(content: string): TomlModelFallbackField { + const lines = content.split(/\r?\n/); + const state: TomlScanState = { inMultilineString: null, arrayDepth: 0 }; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]!; + if (state.inMultilineString) { + const end = findTomlMultilineStringEnd(line, 0, state.inMultilineString[0]!); + if (end === -1) continue; + state.inMultilineString = null; + scanTomlLine(line.slice(end + 3), state); + continue; + } + if (state.arrayDepth === 0) { + const key = line.match(TOML_MODEL_FALLBACK_KEY); + if (key) { + const rest = `${line.slice(key[0].length)}\n${lines.slice(i + 1).join("\n")}`; + return { present: true, value: parseTomlStringArrayValue(rest) }; + } + } + scanTomlLine(line, state); + } + return { present: false, value: null }; } export function readAgentModelFallback(filePath: string): string[] | null { try { const content = readFileSync(filePath, "utf8"); - const multiline = parseTomlModelFallback(content); - if (multiline) return multiline; - for (const line of content.split(/\r?\n/)) { - const match = line.match(TOML_STRING_ARRAY); - if (!match) continue; - return parseTomlStringArray(match[2] ?? ""); - } + const parsed = parseTomlModelFallbackField(content); + return parsed.present ? parsed.value : null; } catch { return null; } - return null; } export function readCodexAgentModelFallback(role: string, codexHome = CODEX_HOME): string[] { @@ -545,6 +741,29 @@ export function readCodexAgentModelFallback(role: string, codexHome = CODEX_HOME return readAgentModelFallback(file) ?? []; } +/** + * True when the role TOML carries a `model_fallback` key, even an empty array. + * Presence is what matters for the doctor scan: Codex >= 0.146 rejects the + * field as unknown and skips the whole role regardless of its value. Uses the + * same TOML-aware parse as the fallback-reading path, so quoted keys count and + * text inside string literals does not. + */ +export function hasCodexAgentModelFallbackField(role: string, codexHome = CODEX_HOME): boolean { + const file = join(codexHome, "agents", `${role}.toml`); + if (!existsSync(file)) return false; + try { + const content = readFileSync(file, "utf8"); + return parseTomlModelFallbackField(content).present; + } catch { + return false; + } +} + +/** Roles whose TOML still carries `model_fallback`, including empty arrays. */ +export function scanCodexAgentRolesWithTomlModelFallback(codexHome = CODEX_HOME): string[] { + return listCodexAgentRoles(codexHome).filter(role => hasCodexAgentModelFallbackField(role, codexHome)); +} + export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] { const dir = join(codexHome, "agents"); if (!existsSync(dir)) return []; @@ -557,4 +776,4 @@ export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] { export function shouldPrimeSubagentQuota(config: OcxConfig, now = Date.now()): boolean { const last = quotaPrimedAt.get("global") ?? 0; return now - last >= pollIntervalMs(config); -} \ No newline at end of file +} diff --git a/src/config.ts b/src/config.ts index 3d0d1a239f..6217ffa353 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1067,6 +1067,12 @@ const configSchema = z.object({ injectionModel: z.string().optional().catch(undefined), injectionEffort: z.string().optional().catch(undefined), syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), + // Per-primary-model fallback chains. Values must be non-empty string arrays; + // malformed entries degrade to undefined rather than rejecting the whole config. + subagentModelFallbackByModel: z.record( + z.string(), + z.array(z.string().trim().min(1)).min(1), + ).optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), codexAccountNamespaces: codexAccountNamespacesSchema.optional(), diff --git a/src/types.ts b/src/types.ts index 481c15a4b0..4b658a13ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -605,6 +605,17 @@ export interface OcxConfig { * turn to the next available entry before routing. */ subagentModelFallback?: string[]; + /** + * Per-primary-model fallback chains for spawned sub-agents, keyed by the + * requested primary model id (bare native or "provider/model"). Entries for + * the matching key are consulted after the requested model and before the + * global `subagentModelFallback` list. + * + * This is the supported home for per-role fallback metadata: storing it as + * `model_fallback` inside `$CODEX_HOME/agents/*.toml` makes Codex >= 0.146 + * reject the whole role file as an unknown field (#1190). + */ + subagentModelFallbackByModel?: Record; /** * TTL (ms) for cached sub-agent model availability probes. Default 60_000. */ diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 6eb69b2179..c3b1af70ac 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -13,6 +13,8 @@ import { readCodexAgentModelFallback, resetSubagentModelFallbackStateForTests, resolveAgentModelFallbackForPrimary, + resolveConfiguredModelFallbackForPrimary, + scanCodexAgentRolesWithTomlModelFallback, selectAvailableSubagentModel, setSubagentQuotaPrimeForTests, subagentFallbackGuidanceText, @@ -920,6 +922,203 @@ describe("subagent model fallback chain", () => { ]); }); + test("config-keyed per-model fallback resolves for the primary model", () => { + const config = cfg({ + subagentModelFallbackByModel: { + "gpt-5.6-sol": ["alibaba-token-plan/qwen3.8-max", "kimi/k3"], + "other-model": ["xai/grok-4.5"], + }, + }); + expect(resolveConfiguredModelFallbackForPrimary("gpt-5.6-sol", config)).toEqual([ + "alibaba-token-plan/qwen3.8-max", + "kimi/k3", + ]); + expect(resolveConfiguredModelFallbackForPrimary("other-model", config)).toEqual([ + "xai/grok-4.5", + ]); + expect(resolveConfiguredModelFallbackForPrimary("unlisted", config)).toEqual([]); + }); + + test("config-keyed fallback dedupes across keys and preserves account-selector case", () => { + const config = cfg({ + codexAccountNamespaces: { work: "account-a", Work: "account-b" }, + subagentModelFallbackByModel: { + "gpt-5.6-sol": ["work/gpt-5.5", "Work/gpt-5.5", "work/GPT-5.5", "kimi/k3"], + "openrouter/anthropic/claude": ["xai/grok-4.5"], + "openrouter/anthropic-claude": ["kimi/k3", "xai/grok-4.5"], + }, + }); + expect(resolveConfiguredModelFallbackForPrimary("gpt-5.6-sol", config)).toEqual([ + "work/gpt-5.5", + "Work/gpt-5.5", + "kimi/k3", + ]); + expect(resolveConfiguredModelFallbackForPrimary("openrouter/anthropic/claude", config)).toEqual([ + "xai/grok-4.5", + "kimi/k3", + ]); + }); + + test("applySubagentModelFallback prefers config-keyed chains over TOML model_fallback", () => { + const dir = codexHomeFixture(); + writeFileSync(join(dir, "agents", "executor.toml"), [ + "name = \"executor\"", + "model = \"gpt-5.6-sol\"", + "model_fallback = [\"kimi/k3\"]", + "", + ].join("\n"), "utf8"); + updateAccountQuota("pool-a", 95); + const parsed = { + modelId: "gpt-5.6-sol", + options: {}, + context: { messages: [] }, + _rawBody: { model: "gpt-5.6-sol" }, + }; + const result = applySubagentModelFallback( + parsed as never, + new Headers({ "x-openai-subagent": "collab_spawn" }), + cfg({ + subagentModelFallback: undefined, + subagentModelFallbackByModel: { + "gpt-5.6-sol": ["alibaba-token-plan/qwen3.8-max"], + }, + }), + ); + expect(result?.to).toBe("alibaba-token-plan/qwen3.8-max"); + }); + + test("scanCodexAgentRolesWithTomlModelFallback reports roles carrying the field, including empty arrays", () => { + const dir = codexHomeFixture(); + writeFileSync(join(dir, "agents", "with_fallback.toml"), [ + "name = \"with_fallback\"", + "model = \"gpt-5.6-sol\"", + "model_fallback = [\"kimi/k3\"]", + "", + ].join("\n"), "utf8"); + writeFileSync(join(dir, "agents", "empty_fallback.toml"), [ + "name = \"empty_fallback\"", + "model = \"gpt-5.6-sol\"", + "model_fallback = []", + "", + ].join("\n"), "utf8"); + writeFileSync(join(dir, "agents", "clean.toml"), [ + "name = \"clean\"", + "model = \"gpt-5.6-sol\"", + "", + ].join("\n"), "utf8"); + expect(scanCodexAgentRolesWithTomlModelFallback(dir).sort()).toEqual([ + "empty_fallback", + "with_fallback", + ]); + expect(readCodexAgentModelFallback("empty_fallback", dir)).toEqual([]); + }); + + test("scanCodexAgentRolesWithTomlModelFallback recognizes quoted model_fallback keys", () => { + const dir = codexHomeFixture(); + writeFileSync(join(dir, "agents", "quoted_key.toml"), [ + "name = \"quoted_key\"", + "model = \"gpt-5.6-sol\"", + "\"model_fallback\" = [\"kimi/k3\"]", + "", + ].join("\n"), "utf8"); + writeFileSync(join(dir, "agents", "literal_key.toml"), [ + "name = \"literal_key\"", + "model = \"gpt-5.6-sol\"", + "'model_fallback' = []", + "", + ].join("\n"), "utf8"); + expect(scanCodexAgentRolesWithTomlModelFallback(dir).sort()).toEqual([ + "literal_key", + "quoted_key", + ]); + expect(readCodexAgentModelFallback("quoted_key", dir)).toEqual(["kimi/k3"]); + expect(readCodexAgentModelFallback("literal_key", dir)).toEqual([]); + }); + + test("scanCodexAgentRolesWithTomlModelFallback ignores model_fallback text inside strings", () => { + const dir = codexHomeFixture(); + writeFileSync(join(dir, "agents", "multiline_text.toml"), [ + "name = \"multiline_text\"", + "model = \"gpt-5.6-sol\"", + "description = \"\"\"", + "model_fallback = []", + "\"model_fallback\" = [\"kimi/k3\"]", + "\"\"\"", + "", + ].join("\n"), "utf8"); + writeFileSync(join(dir, "agents", "single_line_text.toml"), [ + "name = \"single_line_text\"", + "model = \"gpt-5.6-sol\"", + "description = \"model_fallback = [\\\"kimi/k3\\\"]\"", + "", + ].join("\n"), "utf8"); + expect(scanCodexAgentRolesWithTomlModelFallback(dir)).toEqual([]); + expect(readCodexAgentModelFallback("multiline_text", dir)).toEqual([]); + }); + + test("scanCodexAgentRolesWithTomlModelFallback handles escaped triple quotes inside multiline strings", () => { + const dir = codexHomeFixture(); + writeFileSync(join(dir, "agents", "escaped_open.toml"), [ + "name = \"escaped_open\"", + "model = \"gpt-5.6-sol\"", + "description = \"\"\"a\\\"\"\"", + "model_fallback = []", + "\"\"\"", + "", + ].join("\n"), "utf8"); + writeFileSync(join(dir, "agents", "escaped_continuation.toml"), [ + "name = \"escaped_continuation\"", + "model = \"gpt-5.6-sol\"", + "description = \"\"\"", + "still inside \\\"\"\"", + "model_fallback = []", + "\"\"\"", + "", + ].join("\n"), "utf8"); + expect(scanCodexAgentRolesWithTomlModelFallback(dir)).toEqual([]); + expect(readCodexAgentModelFallback("escaped_open", dir)).toEqual([]); + expect(readCodexAgentModelFallback("escaped_continuation", dir)).toEqual([]); + }); + + test("readCodexAgentModelFallback rejects string arrays without proper commas", () => { + const dir = codexHomeFixture(); + writeFileSync(join(dir, "agents", "missing_comma.toml"), [ + "name = \"missing_comma\"", + "model = \"gpt-5.6-sol\"", + "model_fallback = [\"kimi/k3\" \"alibaba-token-plan/qwen3.8-max\"]", + "", + ].join("\n"), "utf8"); + writeFileSync(join(dir, "agents", "leading_comma.toml"), [ + "name = \"leading_comma\"", + "model = \"gpt-5.6-sol\"", + "model_fallback = [, \"kimi/k3\"]", + "", + ].join("\n"), "utf8"); + writeFileSync(join(dir, "agents", "trailing_token.toml"), [ + "name = \"trailing_token\"", + "model = \"gpt-5.6-sol\"", + "model_fallback = [\"kimi/k3\"] invalid", + "", + ].join("\n"), "utf8"); + writeFileSync(join(dir, "agents", "inline_comment.toml"), [ + "name = \"inline_comment\"", + "model = \"gpt-5.6-sol\"", + "model_fallback = [\"kimi/k3\"] # keep", + "", + ].join("\n"), "utf8"); + expect(readCodexAgentModelFallback("missing_comma", dir)).toEqual([]); + expect(readCodexAgentModelFallback("leading_comma", dir)).toEqual([]); + expect(readCodexAgentModelFallback("trailing_token", dir)).toEqual([]); + expect(readCodexAgentModelFallback("inline_comment", dir)).toEqual(["kimi/k3"]); + // Doctor reports every role carrying the field, even when its value is malformed. + expect(scanCodexAgentRolesWithTomlModelFallback(dir).sort()).toEqual([ + "inline_comment", + "leading_comma", + "missing_comma", + "trailing_token", + ]); + }); + test("subagentFallbackGuidanceText renders configured chain", () => { expect(subagentFallbackGuidanceText(cfg())).toContain("gpt-5.6-sol"); expect(subagentFallbackGuidanceText(cfg({ subagentModelFallback: undefined }))).toBe("");