From 9281c3a5e6bcc7fda90df15c8c0be3601c859e07 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:27:47 +0800 Subject: [PATCH 1/5] fix(subagent): move per-role model fallback into opencodex config (#1190) Codex 0.146+ strictly deserializes $CODEX_HOME/agents/*.toml and rejects model_fallback as an unknown field, skipping the entire role definition. Per-role fallback chains now live in config.json as subagentModelFallbackByModel, keyed by the requested primary model id, and are consulted before the legacy TOML read (kept for backwards compatibility). ocx doctor scans agent role files and warns when any still carries model_fallback, pointing at the new config home. Docs updated in all locales. --- .../content/docs/guides/sub-agent-surface.md | 10 ++- .../docs/ja/guides/sub-agent-surface.md | 4 +- .../docs/ja/reference/configuration/agents.md | 11 ++- .../docs/ko/guides/sub-agent-surface.md | 4 +- .../docs/ko/reference/configuration/agents.md | 11 ++- .../docs/reference/configuration/agents.md | 11 ++- .../docs/ru/guides/sub-agent-surface.md | 8 +- .../docs/ru/reference/configuration/agents.md | 11 ++- .../docs/zh-cn/guides/sub-agent-surface.md | 4 +- .../zh-cn/reference/configuration/agents.md | 10 ++- src/cli/doctor.ts | 10 +++ src/codex/subagent-model-fallback.ts | 50 ++++++++++- src/config.ts | 6 ++ src/types.ts | 11 +++ tests/subagent-model-fallback.test.ts | 83 +++++++++++++++++++ 15 files changed, 233 insertions(+), 11 deletions(-) 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..31516724b9 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` в agent TOML Codex заставляет 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..3cab8a1883 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( @@ -545,6 +582,17 @@ export function readCodexAgentModelFallback(role: string, codexHome = CODEX_HOME return readAgentModelFallback(file) ?? []; } +/** + * Roles whose TOML still carries `model_fallback`. Codex >= 0.146 rejects the + * entire role file as an unknown field (#1190), so these files are skipped by + * the native runtime even though opencodex can still read them. + */ +export function scanCodexAgentRolesWithTomlModelFallback(codexHome = CODEX_HOME): string[] { + return listCodexAgentRoles(codexHome).filter( + role => readCodexAgentModelFallback(role, codexHome).length > 0, + ); +} + export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] { const dir = join(codexHome, "agents"); if (!existsSync(dir)) return []; 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..90ad97327d 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,87 @@ 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 only roles carrying the field", () => { + 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", "clean.toml"), [ + "name = \"clean\"", + "model = \"gpt-5.6-sol\"", + "", + ].join("\n"), "utf8"); + expect(scanCodexAgentRolesWithTomlModelFallback(dir)).toEqual(["with_fallback"]); + }); + test("subagentFallbackGuidanceText renders configured chain", () => { expect(subagentFallbackGuidanceText(cfg())).toContain("gpt-5.6-sol"); expect(subagentFallbackGuidanceText(cfg({ subagentModelFallback: undefined }))).toBe(""); From f666b0241e86353cf3f4defb1208f7eaef37ab2e Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:25:29 +0800 Subject: [PATCH 2/5] fix(doctor): flag model_fallback key presence, including empty arrays CodeRabbit review: Codex >= 0.146 rejects the unknown field regardless of value, so the doctor scan must report model_fallback = [] roles too. Add hasCodexAgentModelFallbackField and a regression test; clarify the Russian config table wording. --- .../docs/ru/reference/configuration/agents.md | 2 +- src/codex/subagent-model-fallback.ts | 22 ++++++++++++++----- tests/subagent-model-fallback.test.ts | 14 ++++++++++-- 3 files changed, 29 insertions(+), 9 deletions(-) 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 31516724b9..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,7 +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` в agent TOML Codex заставляет Codex 0.146+ пропустить роль (#1190). | +| `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'а, выигрывает более низкий. | diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 3cab8a1883..7c22990ecb 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -583,14 +583,24 @@ export function readCodexAgentModelFallback(role: string, codexHome = CODEX_HOME } /** - * Roles whose TOML still carries `model_fallback`. Codex >= 0.146 rejects the - * entire role file as an unknown field (#1190), so these files are skipped by - * the native runtime even though opencodex can still read them. + * True when the role TOML carries a readable `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. */ +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 /^\s*model_fallback\s*=/m.test(content); + } 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 => readCodexAgentModelFallback(role, codexHome).length > 0, - ); + return listCodexAgentRoles(codexHome).filter(role => hasCodexAgentModelFallbackField(role, codexHome)); } export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] { diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 90ad97327d..c173f9f435 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -987,7 +987,7 @@ describe("subagent model fallback chain", () => { expect(result?.to).toBe("alibaba-token-plan/qwen3.8-max"); }); - test("scanCodexAgentRolesWithTomlModelFallback reports only roles carrying the field", () => { + test("scanCodexAgentRolesWithTomlModelFallback reports roles carrying the field, including empty arrays", () => { const dir = codexHomeFixture(); writeFileSync(join(dir, "agents", "with_fallback.toml"), [ "name = \"with_fallback\"", @@ -995,12 +995,22 @@ describe("subagent model fallback chain", () => { "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)).toEqual(["with_fallback"]); + expect(scanCodexAgentRolesWithTomlModelFallback(dir).sort()).toEqual([ + "empty_fallback", + "with_fallback", + ]); + expect(readCodexAgentModelFallback("empty_fallback", dir)).toEqual([]); }); test("subagentFallbackGuidanceText renders configured chain", () => { From 2b6a50f4f8bcc7e7a3272a7634525c7e361325ba Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:37:32 +0800 Subject: [PATCH 3/5] fix(doctor): make model_fallback detection TOML-aware CodeRabbit review: hasCodexAgentModelFallbackField matched raw text, so quoted keys like "model_fallback" were missed and the same text inside a multiline string literal was a false positive. Share one TOML-aware, presence-aware parser between the doctor scan and the fallback-reading path; it recognizes quoted keys, skips string contents, and keeps absent vs. empty-array distinct. Add quoted-key and multiline-string tests. --- src/codex/subagent-model-fallback.ts | 177 +++++++++++++++++++++++--- tests/subagent-model-fallback.test.ts | 43 +++++++ 2 files changed, 200 insertions(+), 20 deletions(-) diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 7c22990ecb..ef37e89451 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -547,33 +547,168 @@ 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*=/; -function parseTomlStringArray(raw: string): string[] { - const matches = [...raw.matchAll(/"((?:\\.|[^"\\])*)"/g)]; - return matches.map(match => match[1]!.replace(/\\"/g, "\"")); +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 = line.indexOf(delimiter, i + 3); + 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++; + } } -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] ?? ""); +/** 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; +} + +/** Parse a TOML string-array value; null when the value is not an 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) || ch === ",") { + i += 1; + continue; + } + break; + } + }; + skipIgnored(); + if (text[i] !== "[") return null; + i += 1; + for (;;) { + skipIgnored(); + if (i >= text.length) return null; + const ch = text[i]!; + if (ch === "]") return values; + if (ch === '"' || ch === "'") { + const parsed = parseTomlStringAt(text, i); + if (!parsed) return null; + values.push(parsed.value); + i = parsed.end; + continue; + } + return null; + } +} + +/** + * 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 = line.indexOf(state.inMultilineString); + 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[] { @@ -583,16 +718,18 @@ export function readCodexAgentModelFallback(role: string, codexHome = CODEX_HOME } /** - * True when the role TOML carries a readable `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. + * 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 /^\s*model_fallback\s*=/m.test(content); + return parseTomlModelFallbackField(content).present; } catch { return false; } diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index c173f9f435..554c65340f 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -1013,6 +1013,49 @@ describe("subagent model fallback chain", () => { 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("subagentFallbackGuidanceText renders configured chain", () => { expect(subagentFallbackGuidanceText(cfg())).toContain("gpt-5.6-sol"); expect(subagentFallbackGuidanceText(cfg({ subagentModelFallback: undefined }))).toBe(""); From 7325fbac9ef1877154d81357071ff1141026e06b Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:48:28 +0800 Subject: [PATCH 4/5] fix(doctor): handle escaped delimiters and strict array commas CodeRabbit review: an escaped triple quote (\""") inside a multiline basic string closed scanner state early, and string arrays accepted adjacent elements without commas. Use findTomlMultilineStringEnd in both scanner paths and require one comma between successive array elements while keeping empty and trailing-comma arrays valid. Add regression tests for escaped delimiters and malformed arrays. --- src/codex/subagent-model-fallback.ts | 19 ++++++++--- tests/subagent-model-fallback.test.ts | 47 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index ef37e89451..0c074068e7 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -565,7 +565,7 @@ function scanTomlLine(line: string, state: TomlScanState): void { if (ch === '"' || ch === "'") { const delimiter = ch.repeat(3); if (line.startsWith(delimiter, i)) { - const end = line.indexOf(delimiter, i + 3); + const end = findTomlMultilineStringEnd(line, i + 3, ch); if (end === -1) { state.inMultilineString = delimiter as '"""' | "'''"; return; @@ -634,7 +634,7 @@ function findTomlMultilineStringEnd(text: string, from: number, quote: string): return -1; } -/** Parse a TOML string-array value; null when the value is not an array of strings. */ +/** 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; @@ -645,7 +645,7 @@ function parseTomlStringArrayValue(text: string): string[] | null { while (i < text.length && text[i] !== "\n") i += 1; continue; } - if (/\s/.test(ch) || ch === ",") { + if (/\s/.test(ch)) { i += 1; continue; } @@ -655,16 +655,25 @@ function parseTomlStringArrayValue(text: string): string[] | null { 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 === "]") 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; @@ -683,7 +692,7 @@ function parseTomlModelFallbackField(content: string): TomlModelFallbackField { for (let i = 0; i < lines.length; i += 1) { const line = lines[i]!; if (state.inMultilineString) { - const end = line.indexOf(state.inMultilineString); + const end = findTomlMultilineStringEnd(line, 0, state.inMultilineString[0]!); if (end === -1) continue; state.inMultilineString = null; scanTomlLine(line.slice(end + 3), state); @@ -752,4 +761,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/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 554c65340f..41db837a68 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -1056,6 +1056,53 @@ describe("subagent model fallback chain", () => { 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"); + expect(readCodexAgentModelFallback("missing_comma", dir)).toEqual([]); + expect(readCodexAgentModelFallback("leading_comma", dir)).toEqual([]); + // Doctor still reports both: the field exists even when its value is malformed. + expect(scanCodexAgentRolesWithTomlModelFallback(dir).sort()).toEqual([ + "leading_comma", + "missing_comma", + ]); + }); + test("subagentFallbackGuidanceText renders configured chain", () => { expect(subagentFallbackGuidanceText(cfg())).toContain("gpt-5.6-sol"); expect(subagentFallbackGuidanceText(cfg({ subagentModelFallback: undefined }))).toBe(""); From 6b1ce0cd157de8a5253ced2721dad008b2ab4e65 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:50:36 +0800 Subject: [PATCH 5/5] fix(doctor): reject trailing tokens after the model_fallback array CodeRabbit review: the value parser returned as soon as it read the closing bracket, so 'model_fallback = ["kimi/k3"] invalid' produced a fallback list. After ']' only horizontal whitespace, an inline comment, or the line end is valid; anything else makes the value malformed while presence (and thus the doctor WARN) is still reported. --- src/codex/subagent-model-fallback.ts | 17 ++++++++++++++++- tests/subagent-model-fallback.test.ts | 18 +++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 0c074068e7..db99df8b97 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -634,6 +634,18 @@ function findTomlMultilineStringEnd(text: string, from: number, quote: string): return -1; } +/** 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[] = []; @@ -660,7 +672,10 @@ function parseTomlStringArrayValue(text: string): string[] | null { skipIgnored(); if (i >= text.length) return null; const ch = text[i]!; - if (ch === "]") return values; + if (ch === "]") { + if (!isValidTomlArrayTail(text, i + 1)) return null; + return values; + } if (ch === ",") { if (expectValue) return null; // leading or doubled comma expectValue = true; diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 41db837a68..c3b1af70ac 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -1094,12 +1094,28 @@ describe("subagent model fallback chain", () => { "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([]); - // Doctor still reports both: the field exists even when its value is malformed. + 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", ]); });