Compare commits

..

31 Commits

Author SHA1 Message Date
YeonGyu-Kim
13196aedb7 fix: resolve post-rebase runtime fallback merge leftovers 2026-02-21 02:45:48 +09:00
IYODA Atsushi
a8e3e1ea01 fix(test): correct browserProvider assertion to match actual behavior
When browserProvider is not set, agent-browser skill should NOT resolve.
Test assertions were inverted — expected 'Skills not found' but asserted the opposite.
2026-02-21 02:42:20 +09:00
IYODA Atsushi
fcaaa11a06 fix(runtime-fallback): detect type:error message parts for fallback progression 2026-02-21 02:42:20 +09:00
IYODA Atsushi
f82e65fdd1 docs(runtime-fallback): clarify timeout_seconds=0 disables auto-retry detection 2026-02-21 02:42:20 +09:00
Youngbin Kim
eef80a4e23 chore: regenerate JSON schema after merge 2026-02-21 02:42:20 +09:00
Youngbin Kim
b6456faea8 refactor(runtime-fallback): decompose index.ts into focused modules
Split 1021-line index.ts into 10 focused modules per project conventions.

New structure:

- error-classifier.ts: error analysis with dynamic status code extraction

- agent-resolver.ts: agent detection utilities

- fallback-state.ts: state management and cooldown logic

- fallback-models.ts: model resolution from config

- auto-retry.ts: retry helpers with mutual recursion support

- event-handler.ts: session lifecycle events

- message-update-handler.ts: message.updated event handling

- chat-message-handler.ts: chat message interception

- hook.ts: main factory with proper cleanup

- types.ts: updated with HookDeps interface

- index.ts: 2-line barrel re-export

Embedded fixes:

- Fix setInterval leak with .unref()

- Replace require() with ESM import

- Add log warning on invalid model format

- Update sessionLastAccess on normal traffic

- Make extractStatusCode dynamic from config

- Remove unused SessionErrorInfo type

All 61 tests pass without modification.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-21 02:42:12 +09:00
Youngbin Kim
22dda6178a docs(config): fix runtime fallback documentation
Remove duplicate Runtime Fallback section from configurations.md.

Fix max_fallback_attempts range from (1-10) to (1-20) to match schema.

Update retry_on_errors default to include 400 status code.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-21 02:42:12 +09:00
Youngbin Kim
c54da1e670 docs(config): correct retry_on_errors default in schema comment
Update schema comment to match actual code default [400, 429, 503, 529].

Previously the comment omitted 400 which is included in the code default.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-21 02:41:43 +09:00
Youngbin Kim
1835458054 fix(test): revert atlas test to use uiSelectedModel
Revert test name and assertion to original behavior per PR review feedback.

The test now correctly expects Atlas to respect uiSelectedModel instead of using its own fallback chain.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-21 02:40:47 +09:00
Youngbin Kim
349e820473 fix(config): allow timeout_seconds to be 0 to disable fallback
Previously, the Zod schema rejected timeout_seconds: 0 due to .min(1).
Now it accepts 0-integer values to allow disabling timeout-based fallback.

- Changed z.number().min(1) to z.number().min(0)
- Updated comment to clarify 0 disables timeout checks
- All tests pass (44 runtime-fallback + 46 schema tests)
- Build successful
2026-02-21 02:40:47 +09:00
Youngbin Kim
68f5d982fc feat(runtime-fallback): add timeout toggle for quota retry detection
Make provider auto-retry signal detection respect timeout_seconds setting:
- When timeout_seconds=0, disable quota-based fallback escalation
- Only treat auto-retry signals as errors when timeout is enabled
- Add test to verify behavior when timeout_seconds is disabled
- Update documentation to explain timeout_seconds=0 behavior

This allows users to disable timeout-based fallbacks while keeping
error-based fallback functionality intact.
2026-02-21 02:40:47 +09:00
Youngbin Kim
8b2ae957e5 feat(runtime-fallback): generalize provider auto-retry signal detection
Refactor retry signal detection to be provider-agnostic:
- Replace hardcoded Copilot/OpenAI checks with generic pattern matching
- Detect any provider message containing limit/quota keywords + [retrying in X]
- Add OpenAI pattern: 'usage limit has been reached [retrying in X]'
- Update logging to use generic 'provider' instead of specific names
- Add 'usage limit has been reached' to RETRYABLE_ERROR_PATTERNS

This enables fallback escalation for any provider that signals automatic
retries due to quota/rate limits, not just Copilot and OpenAI.

Closes PR discussion: generalize retry pattern detection
2026-02-21 02:40:47 +09:00
Youngbin Kim
31f61078b1 docs(runtime-fallback): document retry classes and timeout behavior 2026-02-21 02:40:47 +09:00
Youngbin Kim
6a97f00a22 feat(runtime-fallback): add configurable session timeout controls 2026-02-21 02:40:01 +09:00
Youngbin Kim
ff230df47c fix(runtime-fallback): harden fallback progression and success detection 2026-02-21 02:40:01 +09:00
Youngbin Kim
5a406cab9e refactor(runtime-fallback): extract auto-retry helper and fix provider constraint inconsistency
- Extract duplicated auto-retry logic (~40 lines each) from session.error and
  message.updated handlers into shared autoRetryWithFallback() helper
- Fix userFallbackModels path in model-resolution-pipeline to respect
  constraints.connectedProviders parameter instead of reading cache directly,
  matching the behavior of categoryDefaultModel and fallbackChain paths
2026-02-21 02:40:01 +09:00
Youngbin Kim
fbafb8cf67 fix(runtime-fallback): 9 critical bug fixes for auto-retry, agent preservation, and model override
Bug fixes:
1. extractStatusCode: handle nested data.statusCode (Anthropic error structure)
2. Error regex: relax credit.*balance.*too.*low pattern for multi-char gaps
3. Zod schema: bump max_fallback_attempts from 10 to 20 (config rejected silently)
4. getFallbackModelsForSession: fallback to sisyphus/any agent when session.error lacks agent
5. Model detection: derive model from agent config when session.error lacks model info
6. Auto-retry: resend last user message with fallback model via promptAsync
7. Persistent fallback: override model on every chat.message (not just pendingFallbackModel)
8. Manual model change: detect UI model changes and reset fallback state
9. Agent preservation: include agent in promptAsync body to prevent defaulting to sisyphus

Additional:
- Add sessionRetryInFlight guard to prevent double-retries
- Add resolveAgentForSession with 3-tier resolution (event → session memory → session ID)
- Add normalizeAgentName for display names like "Prometheus (Planner)" → "prometheus"
- Add resolveAgentForSessionFromContext to fetch agent from session messages
- Move AGENT_NAMES and agentPattern to module scope for reuse
- Register runtime-fallback hooks in event.ts and chat-message.ts
- Remove diagnostic debug logging from isRetryableError
- Add 400 to default retry_on_errors and credit/balance patterns to RETRYABLE_ERROR_PATTERNS
2026-02-21 02:39:41 +09:00
youming.tang
708b9ce9ff fix(runtime-fallback): sort agent names by length to fix hyphenated agent detection
The \b word boundary regex treats '-' as a boundary, causing
'sisyphus-junior-session-123' to incorrectly match 'sisyphus'
instead of 'sisyphus-junior'.

Sorting agent names by length (descending) ensures longer names
are matched first, fixing the hyphenated agent detection issue.

Fixes cubic-dev-ai review issue #8
2026-02-21 02:38:17 +09:00
um1ng
d9072b4a98 fix(runtime-fallback): address cubic AI review issues
- Add normalizeFallbackModels helper to centralize string/array normalization (P3)
- Export RuntimeFallbackConfig and FallbackModels types from config/index.ts
- Fix agent detection regex to use word boundaries for sessionID matching
- Improve tests to verify actual fallback switching logic (not just log paths)
- Add SessionCategoryRegistry cleanup in executeSyncTask on completion/error (P2)
- All 24 runtime-fallback tests pass, 115 delegate-task tests pass
2026-02-21 02:37:57 +09:00
um1ng
e9ec4f44e2 feat(runtime-fallback): automatic model switching on API errors
Implements runtime model fallback that automatically switches to backup models
when the primary model encounters transient errors (rate limits, overload, etc.).

Features:
- runtime_fallback configuration with customizable error codes, cooldown, notifications
- Runtime fallback hook intercepts API errors (429, 503, 529)
- Support for fallback_models from agent/category configuration
- Session-state TTL and periodic cleanup to prevent memory leaks
- Robust agent name detection with explicit AGENT_NAMES array
- Session category registry for category-specific fallback lookup

Schema changes:
- Add RuntimeFallbackConfigSchema with enabled, retry_on_errors, max_fallback_attempts,
  cooldown_seconds, notify_on_fallback options
- Add fallback_models to AgentOverrideConfigSchema and CategoryConfigSchema
- Add runtime-fallback to HookNameSchema

Files added:
- src/hooks/runtime-fallback/index.ts - Main hook implementation
- src/hooks/runtime-fallback/types.ts - Type definitions
- src/hooks/runtime-fallback/constants.ts - Constants and defaults
- src/hooks/runtime-fallback/index.test.ts - Comprehensive tests
- src/config/schema/runtime-fallback.ts - Schema definition
- src/shared/session-category-registry.ts - Session category tracking

Files modified:
- src/hooks/index.ts - Export runtime-fallback hook
- src/plugin/hooks/create-session-hooks.ts - Register runtime-fallback hook
- src/config/schema.ts - Export runtime-fallback schema
- src/config/schema/oh-my-opencode-config.ts - Add runtime_fallback config
- src/config/schema/agent-overrides.ts - Add fallback_models to agent config
- src/config/schema/categories.ts - Add fallback_models to category config
- src/config/schema/hooks.ts - Add runtime-fallback to hook names
- src/shared/index.ts - Export session-category-registry
- docs/configurations.md - Add Runtime Fallback documentation
- docs/features.md - Add runtime-fallback to hooks list

Supersedes #1237, #1408
Closes #1408
2026-02-21 02:36:56 +09:00
youming.tang
067c8010be fix: resolve merge conflicts in PR #1408
- Fix bun.lock version conflicts (3.3.1 -> 3.3.2)
- Remove Git conflict markers from docs/configurations.md
- Remove duplicate normalizeFallbackModels, import from shared module
2026-02-21 02:35:03 +09:00
um1ng
17d43672ad refactor(shared): add normalizeFallbackModels utility function
Add shared utility to normalize fallback_models config values.

Handles both single string and array inputs consistently.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-21 02:34:28 +09:00
um1ng
8873896432 fix(runtime-fallback): use precise regex patterns for status code matching
Replace word-boundary regex with stricter patterns that match

status codes only at start/end of string or surrounded by whitespace.

Prevents false matches like '1429' or '4290'.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-21 02:33:49 +09:00
youming.tang
a206daa437 test(agents): update Atlas uiSelectedModel expectation 2026-02-21 02:33:49 +09:00
youming.tang
538a92ab12 test(delegate-task): stabilize browserProvider and default variant cases 2026-02-21 02:33:49 +09:00
youming.tang
cd3e0ca124 fix(session-category-registry): cleanup entries for task sessions 2026-02-21 02:31:42 +09:00
youming.tang
d947743932 fix(runtime-fallback): per-model cooldown and stricter retry patterns 2026-02-21 02:30:55 +09:00
youming.tang
0ef17aa6c9 docs: add runtime-fallback and fallback_models documentation 2026-02-21 02:30:45 +09:00
Ultrawork Bot
7aafa13b21 feat(fallback_models): complete init-time and runtime integration
Implement full fallback_models support across all integration points:

1. Model Resolution Pipeline (src/shared/model-resolution-pipeline.ts)
   - Add userFallbackModels to ModelResolutionRequest
   - Process user fallback_models before hardcoded fallback chain
   - Support both connected provider and availability checking modes

2. Agent Utils (src/agents/utils.ts)
   - Update applyModelResolution to accept userFallbackModels
   - Inject fallback_models for all builtin agents (sisyphus, oracle, etc.)
   - Support both single string and array formats

3. Model Resolver (src/shared/model-resolver.ts)
   - Add userFallbackModels to ExtendedModelResolutionInput type
   - Pass through to resolveModelPipeline

4. Delegate Task Executor (src/tools/delegate-task/executor.ts)
   - Extract category fallback_models configuration
   - Pass to model resolution pipeline
   - Register session category for runtime-fallback hook

5. Session Category Registry (src/shared/session-category-registry.ts)
   - New module: maps sessionID -> category
   - Used by runtime-fallback to lookup category fallback_models
   - Auto-cleanup support

6. Runtime Fallback Hook (src/hooks/runtime-fallback/index.ts)
   - Check SessionCategoryRegistry first for category fallback_models
   - Fallback to agent-level configuration
   - Import and use SessionCategoryRegistry

Test Results:
- runtime-fallback: 24/24 tests passing
- model-resolver: 46/46 tests passing

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-21 02:30:01 +09:00
Rebase Bot
6dc1aff698 fix(runtime-fallback): add Category support and expand test coverage
- Add Category-level fallback_models support in getFallbackModelsForSession()
  - Try agent-level fallback_models first
  - Then try agent's category fallback_models
  - Support all builtin agents including hephaestus, sisyphus-junior, build, plan

- Expand agent name recognition regex to include:
  - hephaestus, sisyphus-junior, build, plan, multimodal-looker

- Add comprehensive test coverage (6 new tests, total 24):
  - Model switching via chat.message hook
  - Agent-level fallback_models configuration
  - SessionID agent pattern detection
  - Cooldown mechanism validation
  - Max attempts limit enforcement

All 24 tests passing

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-21 02:28:27 +09:00
Rebase Bot
632570f7ec feat(config): add runtime_fallback and fallback_models schema
Add configuration schemas for runtime model fallback feature:
- RuntimeFallbackConfigSchema with enabled, retry_on_errors,
  max_fallback_attempts, cooldown_seconds, notify_on_fallback
- FallbackModelsSchema for init-time fallback model selection
- Add fallback_models to AgentOverrideConfigSchema and CategoryConfigSchema
- Export types and schemas from config/index.ts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-21 02:28:27 +09:00
77 changed files with 4537 additions and 2646 deletions

View File

@@ -69,7 +69,6 @@
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"model-fallback",
"anthropic-context-window-limit-recovery",
"preemptive-compaction",
"rules-injector",
@@ -81,7 +80,6 @@
"non-interactive-env",
"interactive-bash-session",
"thinking-block-validator",
"beast-mode-system",
"ralph-loop",
"category-skill-reminder",
"compaction-context-injector",
@@ -102,6 +100,7 @@
"task-resume-info",
"stop-continuation-guard",
"tasks-todowrite-disabler",
"runtime-fallback",
"write-existing-file-guard",
"anthropic-effort",
"hashline-read-enhancer",
@@ -142,6 +141,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -327,6 +339,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -512,6 +537,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -697,6 +735,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -882,6 +933,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -1067,6 +1131,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -1252,6 +1329,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -1437,6 +1527,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -1622,6 +1725,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -1807,6 +1923,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -1992,6 +2121,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -2177,6 +2319,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -2362,6 +2517,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -2547,6 +2715,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -2740,6 +2921,19 @@
"model": {
"type": "string"
},
"fallback_models": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"variant": {
"type": "string"
},
@@ -3011,9 +3205,6 @@
},
"disable_omo_env": {
"type": "boolean"
},
"model_fallback_title": {
"type": "boolean"
}
},
"additionalProperties": false
@@ -3152,6 +3343,37 @@
],
"additionalProperties": false
},
"runtime_fallback": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"retry_on_errors": {
"type": "array",
"items": {
"type": "number"
}
},
"max_fallback_attempts": {
"type": "number",
"minimum": 1,
"maximum": 20
},
"cooldown_seconds": {
"type": "number",
"minimum": 0
},
"timeout_seconds": {
"type": "number",
"minimum": 0
},
"notify_on_fallback": {
"type": "boolean"
}
},
"additionalProperties": false
},
"background_task": {
"type": "object",
"properties": {

View File

@@ -163,19 +163,20 @@ Override built-in agent settings:
}
```
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`, `category`, `variant`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `providerOptions`.
Each agent supports: `model`, `fallback_models`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`, `category`, `variant`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `providerOptions`.
### Additional Agent Options
| Option | Type | Description |
| ------------------- | ------- | ----------------------------------------------------------------------------------------------- |
| `category` | string | Category name to inherit model and other settings from category defaults |
| `variant` | string | Model variant (e.g., `max`, `high`, `medium`, `low`, `xhigh`) |
| `maxTokens` | number | Maximum tokens for response. Passed directly to OpenCode SDK. |
| `thinking` | object | Extended thinking configuration for Anthropic models. See [Thinking Options](#thinking-options) below. |
| `reasoningEffort` | string | OpenAI reasoning effort level. Values: `low`, `medium`, `high`, `xhigh`. |
| `textVerbosity` | string | Text verbosity level. Values: `low`, `medium`, `high`. |
| `providerOptions` | object | Provider-specific options passed directly to OpenCode SDK. |
| Option | Type | Description |
| ------------------- | -------------- | ----------------------------------------------------------------------------------------------- |
| `fallback_models` | string/array | Fallback models for runtime switching on API errors. Single string or array of model strings. |
| `category` | string | Category name to inherit model and other settings from category defaults |
| `variant` | string | Model variant (e.g., `max`, `high`, `medium`, `low`, `xhigh`) |
| `maxTokens` | number | Maximum tokens for response. Passed directly to OpenCode SDK. |
| `thinking` | object | Extended thinking configuration for Anthropic models. See [Thinking Options](#thinking-options) below. |
| `reasoningEffort` | string | OpenAI reasoning effort level. Values: `low`, `medium`, `high`, `xhigh`. |
| `textVerbosity` | string | Text verbosity level. Values: `low`, `medium`, `high`. |
| `providerOptions` | object | Provider-specific options passed directly to OpenCode SDK. |
#### Thinking Options (Anthropic)
@@ -714,6 +715,84 @@ Configure concurrency limits for background agent tasks. This controls how many
- Allow more concurrent tasks for fast/cheap models (e.g., Gemini Flash)
- Respect provider rate limits by setting provider-level caps
## Runtime Fallback
Automatically switch to backup models when the primary model encounters retryable API errors (rate limits, overload, etc.) or provider key misconfiguration errors (for example, missing API key). This keeps conversations running without manual intervention.
```json
{
"runtime_fallback": {
"enabled": true,
"retry_on_errors": [400, 429, 503, 529],
"max_fallback_attempts": 3,
"cooldown_seconds": 60,
"timeout_seconds": 30,
"notify_on_fallback": true
}
}
```
| Option | Default | Description |
| ----------------------- | ---------------------- | --------------------------------------------------------------------------- |
| `enabled` | `true` | Enable runtime fallback |
| `retry_on_errors` | `[400, 429, 503, 529]` | HTTP status codes that trigger fallback (rate limit, service unavailable). Also supports certain classified provider errors (for example, missing API key) that do not expose HTTP status codes. |
| `max_fallback_attempts` | `3` | Maximum fallback attempts per session (1-20) |
| `cooldown_seconds` | `60` | Cooldown in seconds before retrying a failed model |
| `timeout_seconds` | `30` | Timeout in seconds for an in-flight fallback request before forcing the next fallback model. **⚠️ Set to `0` to disable auto-retry signal detection** (see below). |
| `notify_on_fallback` | `true` | Show toast notification when switching to a fallback model |
### timeout_seconds: Understanding the 0 Value
**⚠️ IMPORTANT**: Setting `timeout_seconds: 0` **disables auto-retry signal detection**. This is a critical behavior change:
| Setting | Behavior |
|---------|----------|
| `timeout_seconds: 30` (default) | ✅ **Full fallback coverage**: Error-based fallback (429, 503, etc.) + auto-retry signal detection (provider messages like "retrying in 8h") |
| `timeout_seconds: 0` | ⚠️ **Limited fallback**: Only error-based fallback works. Provider retry messages are **completely ignored**. Timeout-based escalation is **disabled**. |
**When `timeout_seconds: 0`:**
- ✅ HTTP errors (429, 503, 529) still trigger fallback
- ✅ Provider key errors (missing API key) still trigger fallback
- ❌ Provider retry messages ("retrying in Xh") are **ignored**
- ❌ Timeout-based escalation is **disabled**
- ❌ Hanging requests do **not** advance to the next fallback model
**Recommendation**: Use a non-zero value (e.g., `30` seconds) to enable full fallback coverage. Only set to `0` if you explicitly want to disable auto-retry signal detection.
### How It Works
1. When an API error matching `retry_on_errors` occurs (or a classified provider key error such as missing API key), the hook intercepts it
2. The next request automatically uses the next available model from `fallback_models`
3. Failed models enter a cooldown period before being retried
4. If `timeout_seconds > 0` and a fallback provider hangs, timeout advances to the next fallback model
5. Toast notification (optional) informs you of the model switch
### Configuring Fallback Models
Define `fallback_models` at the agent or category level:
```json
{
"agents": {
"sisyphus": {
"model": "anthropic/claude-opus-4-5",
"fallback_models": ["openai/gpt-5.2", "google/gemini-3-pro"]
}
},
"categories": {
"ultrabrain": {
"model": "openai/gpt-5.2-codex",
"fallback_models": ["anthropic/claude-opus-4-5", "google/gemini-3-pro"]
}
}
}
```
When the primary model fails:
1. First fallback: `openai/gpt-5.2`
2. Second fallback: `google/gemini-3-pro`
3. After `max_fallback_attempts`, returns to primary model
## Categories
Categories enable domain-specific task delegation via the `task` tool. Each category applies runtime presets (model, temperature, prompt additions) when calling the `Sisyphus-Junior` agent.
@@ -830,15 +909,75 @@ Add your own categories or override built-in ones:
}
```
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`, `variant`, `description`, `is_unstable_agent`.
Each category supports: `model`, `fallback_models`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`, `variant`, `description`, `is_unstable_agent`.
### Additional Category Options
| Option | Type | Default | Description |
| ------------------ | ------- | ------- | --------------------------------------------------------------------------------------------------- |
| `description` | string | - | Human-readable description of the category's purpose. Shown in task prompt. |
| `is_unstable_agent`| boolean | `false` | Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini models. |
| Option | Type | Default | Description |
| ------------------- | ------------ | ------- | --------------------------------------------------------------------------------------------------- |
| `fallback_models` | string/array | - | Fallback models for runtime switching on API errors. Single string or array of model strings. |
| `description` | string | - | Human-readable description of the category's purpose. Shown in delegate_task prompt. |
| `is_unstable_agent` | boolean | `false` | Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini models. |
## Runtime Fallback
Automatically switch to backup models when the primary model encounters retryable API errors (rate limits, overload, etc.) or provider key misconfiguration errors (for example, missing API key). This keeps conversations running without manual intervention.
```json
{
"runtime_fallback": {
"enabled": true,
"retry_on_errors": [429, 503, 529],
"max_fallback_attempts": 3,
"cooldown_seconds": 60,
"timeout_seconds": 30,
"notify_on_fallback": true
}
}
```
| Option | Default | Description |
| ----------------------- | ----------------- | --------------------------------------------------------------------------- |
| `enabled` | `true` | Enable runtime fallback |
| `retry_on_errors` | `[429, 503, 529]` | HTTP status codes that trigger fallback (rate limit, service unavailable). Also supports certain classified provider errors (for example, missing API key) that do not expose HTTP status codes. |
| `max_fallback_attempts` | `3` | Maximum fallback attempts per session (1-10) |
| `cooldown_seconds` | `60` | Cooldown in seconds before retrying a failed model |
| `timeout_seconds` | `30` | Timeout in seconds for an in-flight fallback request before forcing the next fallback model. Set to `0` to disable timeout-based fallback and provider quota retry signal detection. |
| `notify_on_fallback` | `true` | Show toast notification when switching to a fallback model |
### How It Works
1. When an API error matching `retry_on_errors` occurs (or a classified provider key error such as missing API key), the hook intercepts it
2. The next request automatically uses the next available model from `fallback_models`
3. Failed models enter a cooldown period before being retried
4. If a fallback provider hangs, timeout advances to the next fallback model
5. Toast notification (optional) informs you of the model switch
### Configuring Fallback Models
Define `fallback_models` at the agent or category level:
```json
{
"agents": {
"sisyphus": {
"model": "anthropic/claude-opus-4-5",
"fallback_models": ["openai/gpt-5.2", "google/gemini-3-pro"]
}
},
"categories": {
"ultrabrain": {
"model": "openai/gpt-5.2-codex",
"fallback_models": ["anthropic/claude-opus-4-5", "google/gemini-3-pro"]
}
}
}
```
When the primary model fails:
1. First fallback: `openai/gpt-5.2`
2. Second fallback: `google/gemini-3-pro`
3. After `max_fallback_attempts`, returns to primary model
## Model Resolution System
At runtime, Oh My OpenCode uses a 3-step resolution process to determine which model to use for each agent and category. This happens dynamically based on your configuration and available models.
@@ -973,7 +1112,7 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
}
```
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `no-sisyphus-gpt`, `start-work`
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `no-sisyphus-gpt`, `start-work`, `runtime-fallback`
**Note on `directory-agents-injector`**: This hook is **automatically disabled** when running on OpenCode 1.1.37+ because OpenCode now has native support for dynamically resolving AGENTS.md files from subdirectories (PR #10678). This prevents duplicate AGENTS.md injection. For older OpenCode versions, the hook remains active to provide the same functionality.

View File

@@ -352,6 +352,7 @@ Hooks intercept and modify behavior at key points in the agent lifecycle.
| **session-recovery** | Stop | Recovers from session errors - missing tool results, thinking block issues, empty messages. |
| **anthropic-context-window-limit-recovery** | Stop | Handles Claude context window limits gracefully. |
| **background-compaction** | Stop | Auto-compacts sessions hitting token limits. |
| **runtime-fallback** | Event | Automatically switches to backup models on retryable API errors (e.g., 429, 503, 529), provider key misconfiguration errors (e.g., missing API key), and auto-retry signals (when `timeout_seconds > 0`). Configurable retry logic with per-model cooldown. See [Runtime Fallback Configuration](configurations.md#runtime-fallback) for details on `timeout_seconds` behavior. |
#### Truncation & Context Management

View File

@@ -100,6 +100,7 @@ export type AgentName = BuiltinAgentName
export type AgentOverrideConfig = Partial<AgentConfig> & {
prompt_append?: string
variant?: string
fallback_models?: string | string[]
}
export type AgentOverrides = Partial<Record<OverridableAgentName, AgentOverrideConfig>>

View File

@@ -51,7 +51,7 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.sisyphus.thinking).toBeUndefined()
})
test("Atlas uses uiSelectedModel when provided", async () => {
test("Atlas uses uiSelectedModel", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["openai/gpt-5.2", "anthropic/claude-sonnet-4-6"])

View File

@@ -15,7 +15,7 @@ describe("model-resolution check", () => {
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
expect(sisyphus).toBeDefined()
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-6")
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("quotio")
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("anthropic")
})
it("returns category requirements with provider chains", async () => {
@@ -26,8 +26,8 @@ describe("model-resolution check", () => {
// then: Should have category entries
const visual = info.categories.find((c) => c.name === "visual-engineering")
expect(visual).toBeDefined()
expect(visual!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-6-thinking")
expect(visual!.requirement.fallbackChain[0]?.providers).toContain("quotio")
expect(visual!.requirement.fallbackChain[0]?.model).toBe("gemini-3-pro")
expect(visual!.requirement.fallbackChain[0]?.providers).toContain("google")
})
})
@@ -87,7 +87,7 @@ describe("model-resolution check", () => {
expect(sisyphus).toBeDefined()
expect(sisyphus!.userOverride).toBeUndefined()
expect(sisyphus!.effectiveResolution).toContain("Provider fallback:")
expect(sisyphus!.effectiveResolution).toContain("quotio")
expect(sisyphus!.effectiveResolution).toContain("anthropic")
})
it("captures user variant for agent when configured", async () => {

View File

@@ -1,6 +1,8 @@
import type { FallbackEntry } from "../shared/model-requirements"
import {
AGENT_MODEL_REQUIREMENTS,
type FallbackEntry,
} from "../shared/model-requirements"
import type { ProviderAvailability } from "./model-fallback-types"
import { CLI_AGENT_MODEL_REQUIREMENTS } from "./model-fallback-requirements"
import { isProviderAvailable } from "./provider-availability"
import { transformModelForProvider } from "./provider-model-id-transform"
@@ -23,7 +25,7 @@ export function resolveModelFromChain(
}
export function getSisyphusFallbackChain(): FallbackEntry[] {
return CLI_AGENT_MODEL_REQUIREMENTS.sisyphus.fallbackChain
return AGENT_MODEL_REQUIREMENTS.sisyphus.fallbackChain
}
export function isAnyFallbackEntryAvailable(

View File

@@ -1,153 +0,0 @@
import type { ModelRequirement } from "../shared/model-requirements"
// NOTE: These requirements are used by the CLI config generator (`generateModelConfig`).
// They intentionally use "install-time" provider IDs (anthropic/openai/google/opencode/etc),
// not runtime providers like `quotio`/`nvidia`.
export const CLI_AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
sisyphus: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
{ providers: ["opencode"], model: "glm-4.7-free" },
],
requiresAnyModel: true,
},
hephaestus: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
],
requiresProvider: ["openai", "github-copilot", "opencode"],
},
oracle: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
],
},
librarian: {
fallbackChain: [
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
{ providers: ["opencode"], model: "glm-4.7-free" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
],
},
explore: {
fallbackChain: [
{ providers: ["github-copilot"], model: "grok-code-fast-1" },
{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["opencode"], model: "gpt-5-nano" },
],
},
"multimodal-looker": {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
{ providers: ["zai-coding-plan"], model: "glm-4.6v" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["opencode"], model: "gpt-5-nano" },
],
},
prometheus: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
],
},
metis: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
],
},
momus: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
],
},
atlas: {
fallbackChain: [
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
],
},
}
export const CLI_CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"visual-engineering": {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["zai-coding-plan"], model: "glm-5" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
],
},
ultrabrain: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
],
},
deep: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
],
requiresModel: "gpt-5.3-codex",
},
artistry: {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
requiresModel: "gemini-3-pro",
},
quick: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["opencode"], model: "gpt-5-nano" },
],
},
"unspecified-low": {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
],
},
"unspecified-high": {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
],
},
writing: {
fallbackChain: [
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
],
},
}

View File

@@ -1,7 +1,7 @@
import {
CLI_AGENT_MODEL_REQUIREMENTS,
CLI_CATEGORY_MODEL_REQUIREMENTS,
} from "./model-fallback-requirements"
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
} from "../shared/model-requirements"
import type { InstallConfig } from "./types"
import type { AgentConfig, CategoryConfig, GeneratedOmoConfig } from "./model-fallback-types"
@@ -16,9 +16,9 @@ import {
export type { GeneratedOmoConfig } from "./model-fallback-types"
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
const LIBRARIAN_MODEL = "opencode/minimax-m2.5-free"
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
const ULTIMATE_FALLBACK = "opencode/big-pickle"
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
@@ -38,12 +38,12 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
return {
$schema: SCHEMA_URL,
agents: Object.fromEntries(
Object.entries(CLI_AGENT_MODEL_REQUIREMENTS)
Object.entries(AGENT_MODEL_REQUIREMENTS)
.filter(([role, req]) => !(role === "sisyphus" && req.requiresAnyModel))
.map(([role]) => [role, { model: ULTIMATE_FALLBACK }])
),
categories: Object.fromEntries(
Object.keys(CLI_CATEGORY_MODEL_REQUIREMENTS).map((cat) => [cat, { model: ULTIMATE_FALLBACK }])
Object.keys(CATEGORY_MODEL_REQUIREMENTS).map((cat) => [cat, { model: ULTIMATE_FALLBACK }])
),
}
}
@@ -51,9 +51,9 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
const agents: Record<string, AgentConfig> = {}
const categories: Record<string, CategoryConfig> = {}
for (const [role, req] of Object.entries(CLI_AGENT_MODEL_REQUIREMENTS)) {
if (role === "librarian" && avail.zai) {
agents[role] = { model: ZAI_MODEL }
for (const [role, req] of Object.entries(AGENT_MODEL_REQUIREMENTS)) {
if (role === "librarian") {
agents[role] = { model: LIBRARIAN_MODEL }
continue
}
@@ -75,6 +75,7 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
if (req.requiresAnyModel && !isAnyFallbackEntryAvailable(fallbackChain, avail)) {
continue
}
const resolved = resolveModelFromChain(fallbackChain, avail)
if (resolved) {
const variant = resolved.variant ?? req.variant
@@ -99,11 +100,11 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
}
}
for (const [cat, req] of Object.entries(CLI_CATEGORY_MODEL_REQUIREMENTS)) {
for (const [cat, req] of Object.entries(CATEGORY_MODEL_REQUIREMENTS)) {
// Special case: unspecified-high downgrades to unspecified-low when not isMaxPlan
const fallbackChain =
cat === "unspecified-high" && !avail.isMaxPlan
? CLI_CATEGORY_MODEL_REQUIREMENTS["unspecified-low"].fallbackChain
? CATEGORY_MODEL_REQUIREMENTS["unspecified-low"].fallbackChain
: req.fallbackChain
if (req.requiresModel && !isRequiredModelAvailable(req.requiresModel, req.fallbackChain, avail)) {

View File

@@ -11,6 +11,8 @@ export {
RalphLoopConfigSchema,
TmuxConfigSchema,
TmuxLayoutSchema,
RuntimeFallbackConfigSchema,
FallbackModelsSchema,
} from "./schema"
export type {
@@ -29,4 +31,6 @@ export type {
TmuxLayout,
SisyphusConfig,
SisyphusTasksConfig,
RuntimeFallbackConfig,
FallbackModels,
} from "./schema"

View File

@@ -9,11 +9,13 @@ export * from "./schema/comment-checker"
export * from "./schema/commands"
export * from "./schema/dynamic-context-pruning"
export * from "./schema/experimental"
export * from "./schema/fallback-models"
export * from "./schema/git-master"
export * from "./schema/hooks"
export * from "./schema/notification"
export * from "./schema/oh-my-opencode-config"
export * from "./schema/ralph-loop"
export * from "./schema/runtime-fallback"
export * from "./schema/skills"
export * from "./schema/sisyphus"
export * from "./schema/sisyphus-agent"

View File

@@ -1,9 +1,11 @@
import { z } from "zod"
import { FallbackModelsSchema } from "./fallback-models"
import { AgentPermissionSchema } from "./internal/permission"
export const AgentOverrideConfigSchema = z.object({
/** @deprecated Use `category` instead. Model is inherited from category defaults. */
model: z.string().optional(),
fallback_models: FallbackModelsSchema.optional(),
variant: z.string().optional(),
/** Category name to inherit model and other settings from CategoryConfig */
category: z.string().optional(),

View File

@@ -1,9 +1,11 @@
import { z } from "zod"
import { FallbackModelsSchema } from "./fallback-models"
export const CategoryConfigSchema = z.object({
/** Human-readable description of the category's purpose. Shown in task prompt. */
description: z.string().optional(),
model: z.string().optional(),
fallback_models: FallbackModelsSchema.optional(),
variant: z.string().optional(),
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),

View File

@@ -17,10 +17,6 @@ export const ExperimentalConfigSchema = z.object({
safe_hook_creation: z.boolean().optional(),
/** Disable auto-injected <omo-env> context in prompts (experimental) */
disable_omo_env: z.boolean().optional(),
/** Enable hashline_edit tool for improved file editing with hash-based line anchors */
hashline_edit: z.boolean().optional(),
/** Append fallback model info to session title when a runtime fallback occurs (default: false) */
model_fallback_title: z.boolean().optional(),
})
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>

View File

@@ -0,0 +1,5 @@
import { z } from "zod"
export const FallbackModelsSchema = z.union([z.string(), z.array(z.string())])
export type FallbackModels = z.infer<typeof FallbackModelsSchema>

View File

@@ -13,7 +13,6 @@ export const HookNameSchema = z.enum([
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"model-fallback",
"anthropic-context-window-limit-recovery",
"preemptive-compaction",
"rules-injector",
@@ -26,7 +25,6 @@ export const HookNameSchema = z.enum([
"interactive-bash-session",
"thinking-block-validator",
"beast-mode-system",
"ralph-loop",
"category-skill-reminder",
@@ -48,6 +46,7 @@ export const HookNameSchema = z.enum([
"task-resume-info",
"stop-continuation-guard",
"tasks-todowrite-disabler",
"runtime-fallback",
"write-existing-file-guard",
"anthropic-effort",
"hashline-read-enhancer",

View File

@@ -14,6 +14,7 @@ import { GitMasterConfigSchema } from "./git-master"
import { HookNameSchema } from "./hooks"
import { NotificationConfigSchema } from "./notification"
import { RalphLoopConfigSchema } from "./ralph-loop"
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
import { SkillsConfigSchema } from "./skills"
import { SisyphusConfigSchema } from "./sisyphus"
import { SisyphusAgentConfigSchema } from "./sisyphus-agent"
@@ -44,6 +45,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
auto_update: z.boolean().optional(),
skills: SkillsConfigSchema.optional(),
ralph_loop: RalphLoopConfigSchema.optional(),
runtime_fallback: RuntimeFallbackConfigSchema.optional(),
background_task: BackgroundTaskConfigSchema.optional(),
notification: NotificationConfigSchema.optional(),
babysitting: BabysittingConfigSchema.optional(),

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
export const RuntimeFallbackConfigSchema = z.object({
/** Enable runtime fallback (default: true) */
enabled: z.boolean().optional(),
/** HTTP status codes that trigger fallback (default: [400, 429, 503, 529]) */
retry_on_errors: z.array(z.number()).optional(),
/** Maximum fallback attempts per session (default: 3) */
max_fallback_attempts: z.number().min(1).max(20).optional(),
/** Cooldown in seconds before retrying a failed model (default: 60) */
cooldown_seconds: z.number().min(0).optional(),
/** Session-level timeout in seconds to advance fallback when provider hangs (default: 30). Set to 0 to disable auto-retry signal detection (only error-based fallback remains active). */
timeout_seconds: z.number().min(0).optional(),
/** Show toast notification when switching to fallback model (default: true) */
notify_on_fallback: z.boolean().optional(),
})
export type RuntimeFallbackConfig = z.infer<typeof RuntimeFallbackConfigSchema>

View File

@@ -2920,39 +2920,6 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
})
describe("BackgroundManager.handleEvent - session.error", () => {
const defaultRetryFallbackChain = [
{ providers: ["quotio"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["quotio"], model: "gpt-5.3-codex", variant: "high" },
]
const stubProcessKey = (manager: BackgroundManager) => {
;(manager as unknown as { processKey: (key: string) => Promise<void> }).processKey = async () => {}
}
const createRetryTask = (manager: BackgroundManager, input: {
id: string
sessionID: string
description: string
concurrencyKey?: string
fallbackChain?: typeof defaultRetryFallbackChain
}) => {
const task = createMockTask({
id: input.id,
sessionID: input.sessionID,
parentSessionID: "parent-session",
parentMessageID: "msg-retry",
description: input.description,
agent: "sisyphus",
status: "running",
concurrencyKey: input.concurrencyKey,
model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" },
fallbackChain: input.fallbackChain ?? defaultRetryFallbackChain,
attemptCount: 0,
})
getTaskMap(manager).set(task.id, task)
return task
}
test("sets task to error, releases concurrency, and cleans up", async () => {
//#given
const manager = createBackgroundManager()
@@ -3079,135 +3046,6 @@ describe("BackgroundManager.handleEvent - session.error", () => {
manager.shutdown()
})
test("retry path releases current concurrency slot and prefers current provider in fallback entry", async () => {
//#given
const manager = createBackgroundManager()
const concurrencyManager = getConcurrencyManager(manager)
const concurrencyKey = "quotio/claude-opus-4-6-thinking"
await concurrencyManager.acquire(concurrencyKey)
stubProcessKey(manager)
const sessionID = "ses_error_retry"
const task = createRetryTask(manager, {
id: "task-session-error-retry",
sessionID,
description: "task that should retry",
concurrencyKey,
fallbackChain: [
{ providers: ["quotio"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["quotio"], model: "claude-opus-4-5" },
],
})
//#when
manager.handleEvent({
type: "session.error",
properties: {
sessionID,
error: {
name: "UnknownError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
},
})
//#then
expect(task.status).toBe("pending")
expect(task.attemptCount).toBe(1)
expect(task.model).toEqual({
providerID: "quotio",
modelID: "claude-opus-4-6",
variant: "max",
})
expect(task.concurrencyKey).toBeUndefined()
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
manager.shutdown()
})
test("retry path triggers on session.status retry events", async () => {
//#given
const manager = createBackgroundManager()
stubProcessKey(manager)
const sessionID = "ses_status_retry"
const task = createRetryTask(manager, {
id: "task-status-retry",
sessionID,
description: "task that should retry on status",
})
//#when
manager.handleEvent({
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
message: "Provider is overloaded",
},
},
})
//#then
expect(task.status).toBe("pending")
expect(task.attemptCount).toBe(1)
expect(task.model).toEqual({
providerID: "quotio",
modelID: "claude-opus-4-6",
variant: "max",
})
manager.shutdown()
})
test("retry path triggers on message.updated assistant error events", async () => {
//#given
const manager = createBackgroundManager()
stubProcessKey(manager)
const sessionID = "ses_message_updated_retry"
const task = createRetryTask(manager, {
id: "task-message-updated-retry",
sessionID,
description: "task that should retry on message.updated",
})
//#when
manager.handleEvent({
type: "message.updated",
properties: {
info: {
id: "msg_errored",
sessionID,
role: "assistant",
error: {
name: "UnknownError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
},
},
})
//#then
expect(task.status).toBe("pending")
expect(task.attemptCount).toBe(1)
expect(task.model).toEqual({
providerID: "quotio",
modelID: "claude-opus-4-6",
variant: "max",
})
manager.shutdown()
})
})
describe("BackgroundManager queue processing - error tasks are skipped", () => {
@@ -3941,96 +3779,3 @@ describe("BackgroundManager regression fixes - resume and aborted notification",
manager.shutdown()
})
})
describe("BackgroundManager - tool permission spread order", () => {
test("startTask respects explore agent restrictions", async () => {
//#given
let capturedTools: Record<string, unknown> | undefined
const client = {
session: {
get: async () => ({ data: { directory: "/test/dir" } }),
create: async () => ({ data: { id: "session-1" } }),
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
capturedTools = args.body.tools as Record<string, unknown>
return {}
},
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-1",
status: "pending",
queuedAt: new Date(),
description: "test task",
prompt: "test prompt",
agent: "explore",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
const input: import("./types").LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
}
//#when
await (manager as unknown as { startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise<void> })
.startTask({ task, input })
//#then
expect(capturedTools).toBeDefined()
expect(capturedTools?.call_omo_agent).toBe(false)
expect(capturedTools?.task).toBe(false)
expect(capturedTools?.write).toBe(false)
expect(capturedTools?.edit).toBe(false)
manager.shutdown()
})
test("resume respects explore agent restrictions", async () => {
//#given
let capturedTools: Record<string, unknown> | undefined
const client = {
session: {
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
capturedTools = args.body.tools as Record<string, unknown>
return {}
},
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-2",
sessionID: "session-2",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
description: "resume task",
prompt: "resume prompt",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getTaskMap(manager).set(task.id, task)
//#when
await manager.resume({
sessionId: "session-2",
prompt: "continue",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
})
//#then
expect(capturedTools).toBeDefined()
expect(capturedTools?.call_omo_agent).toBe(false)
expect(capturedTools?.task).toBe(false)
expect(capturedTools?.write).toBe(false)
expect(capturedTools?.edit).toBe(false)
manager.shutdown()
})
})

View File

@@ -5,7 +5,6 @@ import type {
LaunchInput,
ResumeInput,
} from "./types"
import type { FallbackEntry } from "../../shared/model-requirements"
import { TaskHistory } from "./task-history"
import {
log,
@@ -13,21 +12,14 @@ import {
normalizePromptTools,
normalizeSDKResponse,
promptWithModelSuggestionRetry,
readConnectedProvidersCache,
readProviderModelsCache,
resolveInheritedPromptTools,
createInternalAgentTextPart,
} from "../../shared"
import { setSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
import { isInsideTmux } from "../../shared/tmux"
import {
shouldRetryError,
getNextFallback,
hasMoreFallbacks,
selectFallbackProvider,
} from "../../shared/model-error-classifier"
import {
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
DEFAULT_STALE_TIMEOUT_MS,
@@ -165,8 +157,6 @@ export class BackgroundManager {
parentAgent: input.parentAgent,
parentTools: input.parentTools,
model: input.model,
fallbackChain: input.fallbackChain,
attemptCount: 0,
category: input.category,
}
@@ -355,10 +345,10 @@ export class BackgroundManager {
system: input.skillContent,
tools: (() => {
const tools = {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent),
}
setSessionTools(sessionID, tools)
return tools
@@ -628,10 +618,10 @@ export class BackgroundManager {
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: (() => {
const tools = {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(existingTask.agent),
}
setSessionTools(existingTask.sessionID!, tools)
return tools
@@ -688,27 +678,6 @@ export class BackgroundManager {
handleEvent(event: Event): void {
const props = event.properties
if (event.type === "message.updated") {
const info = props?.info
if (!info || typeof info !== "object") return
const sessionID = (info as Record<string, unknown>)["sessionID"]
const role = (info as Record<string, unknown>)["role"]
if (typeof sessionID !== "string" || role !== "assistant") return
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
const assistantError = (info as Record<string, unknown>)["error"]
if (!assistantError) return
const errorInfo = {
name: this.extractErrorName(assistantError),
message: this.extractErrorMessage(assistantError),
}
this.tryFallbackRetry(task, errorInfo, "message.updated")
}
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
if (!props || typeof props !== "object" || !("sessionID" in props)) return
const partInfo = props as unknown as MessagePartInfo
@@ -805,29 +774,10 @@ export class BackgroundManager {
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
const errorObj = props?.error as { name?: string; message?: string } | undefined
const errorName = errorObj?.name
const errorMessage = props ? this.getSessionErrorMessage(props) : undefined
const errorInfo = { name: errorName, message: errorMessage }
if (this.tryFallbackRetry(task, errorInfo, "session.error")) return
// Original error handling (no retry)
const errorMsg = errorMessage ?? "Session error"
const canRetry =
shouldRetryError(errorInfo) &&
!!task.fallbackChain &&
hasMoreFallbacks(task.fallbackChain, task.attemptCount ?? 0)
log("[background-agent] Session error - no retry:", {
taskId: task.id,
errorName,
errorMessage: errorMsg?.slice(0, 100),
hasFallbackChain: !!task.fallbackChain,
canRetry,
})
task.status = "error"
task.error = errorMsg
task.error = errorMessage ?? "Session error"
task.completedAt = new Date()
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
@@ -910,130 +860,8 @@ export class BackgroundManager {
subagentSessions.delete(task.sessionID)
}
}
SessionCategoryRegistry.remove(sessionID)
}
if (event.type === "session.status") {
const sessionID = props?.sessionID as string | undefined
const status = props?.status as { type?: string; message?: string } | undefined
if (!sessionID || status?.type !== "retry") return
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
const errorMessage = typeof status.message === "string" ? status.message : undefined
const errorInfo = { name: "SessionRetry", message: errorMessage }
this.tryFallbackRetry(task, errorInfo, "session.status")
}
}
private tryFallbackRetry(
task: BackgroundTask,
errorInfo: { name?: string; message?: string },
source: string,
): boolean {
const fallbackChain = task.fallbackChain
const canRetry =
shouldRetryError(errorInfo) &&
fallbackChain &&
fallbackChain.length > 0 &&
hasMoreFallbacks(fallbackChain, task.attemptCount ?? 0)
if (!canRetry) return false
const attemptCount = task.attemptCount ?? 0
const providerModelsCache = readProviderModelsCache()
const connectedProviders = providerModelsCache?.connected ?? readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
const isReachable = (entry: FallbackEntry): boolean => {
if (!connectedSet) return true
// Gate only on provider connectivity. Provider model lists can be stale/incomplete,
// especially after users manually add models to opencode.json.
return entry.providers.some((p) => connectedSet.has(p))
}
let selectedAttemptCount = attemptCount
let nextFallback: FallbackEntry | undefined
while (fallbackChain && selectedAttemptCount < fallbackChain.length) {
const candidate = getNextFallback(fallbackChain, selectedAttemptCount)
if (!candidate) break
selectedAttemptCount++
if (!isReachable(candidate)) {
log("[background-agent] Skipping unreachable fallback:", {
taskId: task.id,
source,
model: candidate.model,
providers: candidate.providers,
})
continue
}
nextFallback = candidate
break
}
if (!nextFallback) return false
const providerID = selectFallbackProvider(
nextFallback.providers,
task.model?.providerID,
)
log("[background-agent] Retryable error, attempting fallback:", {
taskId: task.id,
source,
errorName: errorInfo.name,
errorMessage: errorInfo.message?.slice(0, 100),
attemptCount: selectedAttemptCount,
nextModel: `${providerID}/${nextFallback.model}`,
})
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
if (task.sessionID) {
this.client.session.abort({ path: { id: task.sessionID } }).catch(() => {})
subagentSessions.delete(task.sessionID)
}
const idleTimer = this.idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
this.idleDeferralTimers.delete(task.id)
}
task.attemptCount = selectedAttemptCount
task.model = {
providerID,
modelID: nextFallback.model,
variant: nextFallback.variant,
}
task.status = "pending"
task.sessionID = undefined
task.startedAt = undefined
task.queuedAt = new Date()
task.error = undefined
const key = task.model ? `${task.model.providerID}/${task.model.modelID}` : task.agent
const queue = this.queuesByKey.get(key) ?? []
const retryInput: LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
parentModel: task.parentModel,
parentAgent: task.parentAgent,
parentTools: task.parentTools,
model: task.model,
fallbackChain: task.fallbackChain,
category: task.category,
}
queue.push({ task, input: retryInput })
this.queuesByKey.set(key, queue)
this.processKey(key)
return true
}
markForNotification(task: BackgroundTask): void {
@@ -1196,6 +1024,8 @@ export class BackgroundManager {
this.client.session.abort({
path: { id: task.sessionID },
}).catch(() => {})
SessionCategoryRegistry.remove(task.sessionID)
}
if (options?.skipNotification) {
@@ -1343,6 +1173,8 @@ export class BackgroundManager {
this.client.session.abort({
path: { id: task.sessionID },
}).catch(() => {})
SessionCategoryRegistry.remove(task.sessionID)
}
try {
@@ -1447,13 +1279,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
if (isCompactionAgent(info?.agent)) {
continue
}
const normalizedTools = this.isRecord(info?.tools)
? normalizePromptTools(info.tools as Record<string, boolean | "allow" | "deny" | "ask">)
: undefined
if (info?.agent || info?.model || (info?.modelID && info?.providerID) || normalizedTools) {
agent = info?.agent ?? task.parentAgent
model = info?.model ?? (info?.providerID && info?.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
tools = normalizedTools ?? tools
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
agent = info.agent ?? task.parentAgent
model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
tools = normalizePromptTools(info.tools) ?? tools
break
}
}
@@ -1473,7 +1302,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
tools = normalizePromptTools(currentMessage?.tools) ?? tools
}
const resolvedTools = resolveInheritedPromptTools(task.parentSessionID, tools)
tools = resolveInheritedPromptTools(task.parentSessionID, tools)
log("[background-agent] notifyParentSession context:", {
taskId: task.id,
@@ -1488,7 +1317,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(resolvedTools ? { tools: resolvedTools } : {}),
...(tools ? { tools } : {}),
parts: [createInternalAgentTextPart(notification)],
},
})
@@ -1571,46 +1400,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
return ""
}
private extractErrorName(error: unknown): string | undefined {
if (this.isRecord(error) && typeof error["name"] === "string") return error["name"]
if (error instanceof Error) return error.name
return undefined
}
private extractErrorMessage(error: unknown): string | undefined {
if (!error) return undefined
if (typeof error === "string") return error
if (error instanceof Error) return error.message
if (this.isRecord(error)) {
const dataRaw = error["data"]
const candidates: unknown[] = [
error,
dataRaw,
error["error"],
this.isRecord(dataRaw) ? (dataRaw as Record<string, unknown>)["error"] : undefined,
error["cause"],
]
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.length > 0) return candidate
if (
this.isRecord(candidate) &&
typeof candidate["message"] === "string" &&
candidate["message"].length > 0
) {
return candidate["message"]
}
}
}
try {
return JSON.stringify(error)
} catch {
return String(error)
}
}
private isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
@@ -1688,6 +1477,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
this.tasks.delete(taskId)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
SessionCategoryRegistry.remove(task.sessionID)
}
}
}
@@ -1827,16 +1617,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
// Progress is already tracked via handleEvent(message.part.updated),
// so we skip the expensive session.messages() fetch here.
// Completion will be detected when session transitions to idle.
if (sessionStatus?.type === "retry") {
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
? (sessionStatus as { message?: string }).message
: undefined
const errorInfo = { name: "SessionRetry", message: retryMessage }
if (this.tryFallbackRetry(task, errorInfo, "polling:session.status")) {
continue
}
}
log("[background-agent] Session still running, relying on event-based progress:", {
taskId: task.id,
sessionID,

View File

@@ -141,10 +141,10 @@ export async function startTask(
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent),
},
parts: [{ type: "text", text: input.prompt }],
},
@@ -225,10 +225,10 @@ export async function resumeTask(
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: {
...getAgentToolRestrictions(task.agent),
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(task.agent),
},
parts: [{ type: "text", text: input.prompt }],
},

View File

@@ -1,5 +1,3 @@
import type { FallbackEntry } from "../../shared/model-requirements"
export type BackgroundTaskStatus =
| "pending"
| "running"
@@ -33,10 +31,6 @@ export interface BackgroundTask {
progress?: TaskProgress
parentModel?: { providerID: string; modelID: string }
model?: { providerID: string; modelID: string; variant?: string }
/** Fallback chain for runtime retry on model errors */
fallbackChain?: FallbackEntry[]
/** Number of fallback retry attempts made */
attemptCount?: number
/** Active concurrency slot key */
concurrencyKey?: string
/** Persistent key for re-acquiring concurrency on resume */
@@ -66,8 +60,6 @@ export interface LaunchInput {
parentAgent?: string
parentTools?: Record<string, boolean>
model?: { providerID: string; modelID: string; variant?: string }
/** Fallback chain for runtime retry on model errors */
fallbackChain?: FallbackEntry[]
isUnstableAgent?: boolean
skills?: string[]
skillContent?: string

View File

@@ -1,5 +1,4 @@
export const subagentSessions = new Set<string>()
export const syncSubagentSessions = new Set<string>()
let _mainSessionID: string | undefined
@@ -15,7 +14,6 @@ export function getMainSessionID(): string | undefined {
export function _resetForTesting(): void {
_mainSessionID = undefined
subagentSessions.clear()
syncSubagentSessions.clear()
sessionAgentMap.clear()
}

View File

@@ -25,13 +25,13 @@ export function discoverAllSkillsBlocking(dirs: string[], scopes: SkillScope[]):
const { port1, port2 } = new MessageChannel()
const worker = new Worker(new URL("./discover-worker.ts", import.meta.url), {
// workerData is structured-cloned; pass the SharedArrayBuffer and recreate the view in the worker.
workerData: { signalBuffer: signal.buffer },
workerData: { signal }
})
worker.postMessage({ port: port2 }, [port2])
const input: WorkerInput = { dirs, scopes }
// Avoid a race where the worker hasn't attached listeners to the MessagePort yet.
worker.postMessage({ port: port2, input }, [port2])
port1.postMessage(input)
const waitResult = Atomics.wait(signal, 0, 0, TIMEOUT_MS)

View File

@@ -18,24 +18,25 @@ interface WorkerOutputError {
error: { message: string; stack?: string }
}
const { signalBuffer } = workerData as { signalBuffer: SharedArrayBuffer }
const signal = new Int32Array(signalBuffer)
const { signal } = workerData as { signal: Int32Array }
if (!parentPort) {
throw new Error("Worker must be run with parentPort")
}
parentPort.once("message", (data: { port: MessagePort; input: WorkerInput }) => {
const { port, input } = data
parentPort.once("message", (data: { port: MessagePort }) => {
const { port } = data
void (async () => {
port.on("message", async (input: WorkerInput) => {
try {
const results = await Promise.all(input.dirs.map((dir) => discoverSkillsInDirAsync(dir)))
const results = await Promise.all(
input.dirs.map(dir => discoverSkillsInDirAsync(dir))
)
const skills = results.flat()
const output: WorkerOutputSuccess = { ok: true, skills }
port.postMessage(output)
Atomics.store(signal, 0, 1)
Atomics.notify(signal, 0)
@@ -47,10 +48,10 @@ parentPort.once("message", (data: { port: MessagePort; input: WorkerInput }) =>
stack: error instanceof Error ? error.stack : undefined,
},
}
port.postMessage(output)
Atomics.store(signal, 0, 1)
Atomics.notify(signal, 0)
}
})()
})
})

View File

@@ -217,27 +217,6 @@ describe("TaskToastManager", () => {
expect(call.body.message).toContain("(inherited from parent)")
})
test("should display warning when model is runtime fallback", () => {
// given - runtime-fallback indicates a model swap mid-run
const task = {
id: "task_runtime",
description: "Task with runtime fallback model",
agent: "explore",
isBackground: false,
modelInfo: { model: "quotio/oswe-vscode-prime", type: "runtime-fallback" as const },
}
// when - addTask is called
toastManager.addTask(task)
// then - toast should show fallback warning
expect(mockClient.tui.showToast).toHaveBeenCalled()
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).toContain("[FALLBACK]")
expect(call.body.message).toContain("quotio/oswe-vscode-prime")
expect(call.body.message).toContain("(runtime fallback)")
})
test("should not display model info when user-defined", () => {
// given - a task with user-defined model
const task = {
@@ -278,32 +257,4 @@ describe("TaskToastManager", () => {
expect(call.body.message).not.toContain("[FALLBACK] Model:")
})
})
describe("updateTaskModelBySession", () => {
test("updates task model info and shows fallback toast", () => {
// given - task without model info
const task = {
id: "task_update",
sessionID: "ses_update_1",
description: "Task that will fallback",
agent: "explore",
isBackground: false,
}
toastManager.addTask(task)
mockClient.tui.showToast.mockClear()
// when - runtime fallback applied by session
toastManager.updateTaskModelBySession("ses_update_1", {
model: "nvidia/stepfun-ai/step-3.5-flash",
type: "runtime-fallback",
})
// then - new toast shows fallback model
expect(mockClient.tui.showToast).toHaveBeenCalled()
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).toContain("[FALLBACK]")
expect(call.body.message).toContain("nvidia/stepfun-ai/step-3.5-flash")
expect(call.body.message).toContain("(runtime fallback)")
})
})
})

View File

@@ -20,7 +20,6 @@ export class TaskToastManager {
addTask(task: {
id: string
sessionID?: string
description: string
agent: string
isBackground: boolean
@@ -31,7 +30,6 @@ export class TaskToastManager {
}): void {
const trackedTask: TrackedTask = {
id: task.id,
sessionID: task.sessionID,
description: task.description,
agent: task.agent,
status: task.status ?? "running",
@@ -56,18 +54,6 @@ export class TaskToastManager {
}
}
/**
* Update model info for a task by session ID
*/
updateTaskModelBySession(sessionID: string, modelInfo: ModelFallbackInfo): void {
if (!sessionID) return
const task = Array.from(this.tasks.values()).find((t) => t.sessionID === sessionID)
if (!task) return
if (task.modelInfo?.model === modelInfo.model && task.modelInfo?.type === modelInfo.type) return
task.modelInfo = modelInfo
this.showTaskListToast(task)
}
/**
* Remove completed/error task
*/
@@ -124,17 +110,14 @@ export class TaskToastManager {
const lines: string[] = []
const isFallback = newTask.modelInfo && (
newTask.modelInfo.type === "inherited" ||
newTask.modelInfo.type === "system-default" ||
newTask.modelInfo.type === "runtime-fallback"
newTask.modelInfo.type === "inherited" || newTask.modelInfo.type === "system-default"
)
if (isFallback) {
const suffixMap: Record<"inherited" | "system-default" | "runtime-fallback", string> = {
const suffixMap: Record<"inherited" | "system-default", string> = {
inherited: " (inherited from parent)",
"system-default": " (system default fallback)",
"runtime-fallback": " (runtime fallback)",
}
const suffix = suffixMap[newTask.modelInfo!.type as "inherited" | "system-default" | "runtime-fallback"]
const suffix = suffixMap[newTask.modelInfo!.type as "inherited" | "system-default"]
lines.push(`[FALLBACK] Model: ${newTask.modelInfo!.model}${suffix}`)
lines.push("")
}

View File

@@ -4,13 +4,12 @@ export type TaskStatus = "running" | "queued" | "completed" | "error"
export interface ModelFallbackInfo {
model: string
type: "user-defined" | "inherited" | "category-default" | "system-default" | "runtime-fallback"
type: "user-defined" | "inherited" | "category-default" | "system-default"
source?: ModelSource
}
export interface TrackedTask {
id: string
sessionID?: string
description: string
agent: string
status: TaskStatus

View File

@@ -9,6 +9,45 @@
## HOOK TIERS
### Tier 1: Session Hooks (22) — `create-session-hooks.ts`
## STRUCTURE
```
hooks/
├── atlas/ # Main orchestration (757 lines)
├── anthropic-context-window-limit-recovery/ # Auto-summarize
├── todo-continuation-enforcer.ts # Force TODO completion
├── ralph-loop/ # Self-referential dev loop
├── claude-code-hooks/ # settings.json compat layer - see AGENTS.md
├── comment-checker/ # Prevents AI slop
├── auto-slash-command/ # Detects /command patterns
├── rules-injector/ # Conditional rules
├── directory-agents-injector/ # Auto-injects AGENTS.md
├── directory-readme-injector/ # Auto-injects README.md
├── edit-error-recovery/ # Recovers from failures
├── thinking-block-validator/ # Ensures valid <thinking>
├── context-window-monitor.ts # Reminds of headroom
├── session-recovery/ # Auto-recovers from crashes
├── think-mode/ # Dynamic thinking budget
├── keyword-detector/ # ultrawork/search/analyze modes
├── background-notification/ # OS notification
├── prometheus-md-only/ # Planner read-only mode
├── agent-usage-reminder/ # Specialized agent hints
├── auto-update-checker/ # Plugin update check
├── tool-output-truncator.ts # Prevents context bloat
├── compaction-context-injector/ # Injects context on compaction
├── delegate-task-retry/ # Retries failed delegations
├── interactive-bash-session/ # Tmux session management
├── non-interactive-env/ # Non-TTY environment handling
├── start-work/ # Sisyphus work session starter
├── task-resume-info/ # Resume info for cancelled tasks
├── question-label-truncator/ # Auto-truncates question labels
├── category-skill-reminder/ # Reminds of category skills
├── empty-task-response-detector.ts # Detects empty responses
├── sisyphus-junior-notepad/ # Sisyphus Junior notepad
├── stop-continuation-guard/ # Guards stop continuation
├── subagent-question-blocker/ # Blocks subagent questions
├── runtime-fallback/ # Auto-switch models on API errors
└── index.ts # Hook aggregation + registration
```
| Hook | Event | Purpose |
|------|-------|---------|

View File

@@ -1,54 +0,0 @@
import { describe, expect, test } from "bun:test"
import { clearSessionModel, setSessionModel } from "../../shared/session-model-state"
import { createBeastModeSystemHook, BEAST_MODE_SYSTEM_PROMPT } from "./hook"
describe("beast-mode-system hook", () => {
test("injects beast mode prompt for copilot gpt-4.1", async () => {
//#given
const sessionID = "ses_beast"
setSessionModel(sessionID, { providerID: "github-copilot", modelID: "gpt-4.1" })
const hook = createBeastModeSystemHook()
const output = { system: [] as string[] }
//#when
await hook["experimental.chat.system.transform"]?.({ sessionID }, output)
//#then
expect(output.system[0]).toContain("Beast Mode")
expect(output.system[0]).toContain(BEAST_MODE_SYSTEM_PROMPT.trim().slice(0, 20))
clearSessionModel(sessionID)
})
test("does not inject for other models", async () => {
//#given
const sessionID = "ses_no_beast"
setSessionModel(sessionID, { providerID: "quotio", modelID: "gpt-5.3-codex" })
const hook = createBeastModeSystemHook()
const output = { system: [] as string[] }
//#when
await hook["experimental.chat.system.transform"]?.({ sessionID }, output)
//#then
expect(output.system.length).toBe(0)
clearSessionModel(sessionID)
})
test("avoids duplicate insertion", async () => {
//#given
const sessionID = "ses_dupe"
setSessionModel(sessionID, { providerID: "github-copilot", modelID: "gpt-4.1" })
const hook = createBeastModeSystemHook()
const output = { system: [BEAST_MODE_SYSTEM_PROMPT] }
//#when
await hook["experimental.chat.system.transform"]?.({ sessionID }, output)
//#then
expect(output.system.length).toBe(1)
clearSessionModel(sessionID)
})
})

View File

@@ -1,31 +0,0 @@
import { getSessionModel } from "../../shared/session-model-state"
export const BEAST_MODE_SYSTEM_PROMPT = `Beast Mode (Copilot GPT-4.1)
You are an autonomous coding agent. Execute the task end-to-end.
- Make a brief plan, then act.
- Prefer concrete edits and verification over speculation.
- Run relevant tests when feasible.
- Do not ask the user to perform actions you can do yourself.
- If blocked, state exactly what is needed to proceed.
- Keep responses concise and actionable.`
function isBeastModeModel(model: { providerID: string; modelID: string } | undefined): boolean {
return model?.providerID === "github-copilot" && model.modelID === "gpt-4.1"
}
export function createBeastModeSystemHook() {
return {
"experimental.chat.system.transform": async (
input: { sessionID: string },
output: { system: string[] },
): Promise<void> => {
const model = getSessionModel(input.sessionID)
if (!isBeastModeModel(model)) return
if (output.system.some((entry) => entry.includes("Beast Mode"))) return
output.system.unshift(BEAST_MODE_SYSTEM_PROMPT)
},
}
}

View File

@@ -1 +0,0 @@
export { createBeastModeSystemHook, BEAST_MODE_SYSTEM_PROMPT } from "./hook"

View File

@@ -14,7 +14,6 @@ export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detec
export { createAnthropicContextWindowLimitRecoveryHook, type AnthropicContextWindowLimitRecoveryOptions } from "./anthropic-context-window-limit-recovery";
export { createThinkModeHook } from "./think-mode";
export { createModelFallbackHook, setPendingModelFallback, clearPendingModelFallback, type ModelFallbackState } from "./model-fallback/hook";
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
export { createRulesInjectorHook } from "./rules-injector";
export { createBackgroundNotificationHook } from "./background-notification"
@@ -32,6 +31,7 @@ export { createNoSisyphusGptHook } from "./no-sisyphus-gpt";
export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt";
export { createAutoSlashCommandHook } from "./auto-slash-command";
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
export { createJsonErrorRecoveryHook } from "./json-error-recovery";
export { createPrometheusMdOnlyHook } from "./prometheus-md-only";
export { createSisyphusJuniorNotepadHook } from "./sisyphus-junior-notepad";
export { createTaskResumeInfoHook } from "./task-resume-info";
@@ -45,6 +45,7 @@ export { createCompactionTodoPreserverHook } from "./compaction-todo-preserver";
export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter";
export { createPreemptiveCompactionHook } from "./preemptive-compaction";
export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";
export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallbackOptions } from "./runtime-fallback";
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
export { createBeastModeSystemHook, BEAST_MODE_SYSTEM_PROMPT } from "./beast-mode-system";
export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer";

View File

@@ -1,141 +0,0 @@
import { beforeEach, describe, expect, test } from "bun:test"
import {
clearPendingModelFallback,
createModelFallbackHook,
setPendingModelFallback,
} from "./hook"
describe("model fallback hook", () => {
beforeEach(() => {
clearPendingModelFallback("ses_model_fallback_main")
})
test("applies pending fallback on chat.message by overriding model", async () => {
//#given
const hook = createModelFallbackHook() as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
const set = setPendingModelFallback(
"ses_model_fallback_main",
"Sisyphus (Ultraworker)",
"quotio",
"claude-opus-4-6-thinking",
)
expect(set).toBe(true)
const output = {
message: {
model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" },
variant: "max",
},
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.(
{ sessionID: "ses_model_fallback_main" },
output,
)
//#then
expect(output.message["model"]).toEqual({
providerID: "quotio",
modelID: "claude-opus-4-6",
})
})
test("preserves fallback progression across repeated session.error retries", async () => {
//#given
const hook = createModelFallbackHook() as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
const sessionID = "ses_model_fallback_main"
expect(
setPendingModelFallback(sessionID, "Sisyphus (Ultraworker)", "quotio", "claude-opus-4-6-thinking"),
).toBe(true)
const firstOutput = {
message: {
model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" },
variant: "max",
},
parts: [{ type: "text", text: "continue" }],
}
//#when - first retry is applied
await hook["chat.message"]?.({ sessionID }, firstOutput)
//#then
expect(firstOutput.message["model"]).toEqual({
providerID: "quotio",
modelID: "claude-opus-4-6",
})
//#when - second error re-arms fallback and should advance to next entry
expect(
setPendingModelFallback(sessionID, "Sisyphus (Ultraworker)", "quotio", "claude-opus-4-6"),
).toBe(true)
const secondOutput = {
message: {
model: { providerID: "quotio", modelID: "claude-opus-4-6" },
},
parts: [{ type: "text", text: "continue" }],
}
await hook["chat.message"]?.({ sessionID }, secondOutput)
//#then - chain should progress to entry[1], not repeat entry[0]
expect(secondOutput.message["model"]).toEqual({
providerID: "quotio",
modelID: "gpt-5.3-codex",
})
expect(secondOutput.message["variant"]).toBe("high")
})
test("shows toast when fallback is applied", async () => {
//#given
const toastCalls: Array<{ title: string; message: string }> = []
const hook = createModelFallbackHook({
toast: async ({ title, message }) => {
toastCalls.push({ title, message })
},
}) as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
const set = setPendingModelFallback(
"ses_model_fallback_toast",
"Sisyphus (Ultraworker)",
"quotio",
"claude-opus-4-6-thinking",
)
expect(set).toBe(true)
const output = {
message: {
model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" },
variant: "max",
},
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID: "ses_model_fallback_toast" }, output)
//#then
expect(toastCalls.length).toBe(1)
expect(toastCalls[0]?.title).toBe("Model fallback")
})
})

View File

@@ -1,246 +0,0 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache"
import { selectFallbackProvider } from "../../shared/model-error-classifier"
import { log } from "../../shared/logger"
import { getTaskToastManager } from "../../features/task-toast-manager"
import type { ChatMessageInput, ChatMessageHandlerOutput } from "../../plugin/chat-message"
type FallbackToast = (input: {
title: string
message: string
variant?: "info" | "success" | "warning" | "error"
duration?: number
}) => void | Promise<void>
type FallbackCallback = (input: {
sessionID: string
providerID: string
modelID: string
variant?: string
}) => void | Promise<void>
export type ModelFallbackState = {
providerID: string
modelID: string
fallbackChain: FallbackEntry[]
attemptCount: number
pending: boolean
}
/**
* Map of sessionID -> pending model fallback state
* When a model error occurs, we store the fallback info here.
* The next chat.message call will use this to switch to the fallback model.
*/
const pendingModelFallbacks = new Map<string, ModelFallbackState>()
const lastToastKey = new Map<string, string>()
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
if (!sessionID) return
if (!fallbackChain || fallbackChain.length === 0) {
sessionFallbackChains.delete(sessionID)
return
}
sessionFallbackChains.set(sessionID, fallbackChain)
}
export function clearSessionFallbackChain(sessionID: string): void {
sessionFallbackChains.delete(sessionID)
}
/**
* Sets a pending model fallback for a session.
* Called when a model error is detected in session.error handler.
*/
export function setPendingModelFallback(
sessionID: string,
agentName: string,
currentProviderID: string,
currentModelID: string,
): boolean {
const agentKey = getAgentConfigKey(agentName)
const requirements = AGENT_MODEL_REQUIREMENTS[agentKey]
const sessionFallback = sessionFallbackChains.get(sessionID)
const fallbackChain = sessionFallback && sessionFallback.length > 0
? sessionFallback
: requirements?.fallbackChain
if (!fallbackChain || fallbackChain.length === 0) {
log("[model-fallback] No fallback chain for agent: " + agentName + " (key: " + agentKey + ")")
return false
}
const existing = pendingModelFallbacks.get(sessionID)
if (existing) {
// Preserve progression across repeated session.error retries in same session.
// We only mark the next turn as pending fallback application.
existing.providerID = currentProviderID
existing.modelID = currentModelID
existing.pending = true
if (existing.attemptCount >= existing.fallbackChain.length) {
log("[model-fallback] Fallback chain exhausted for session: " + sessionID)
return false
}
log("[model-fallback] Re-armed pending fallback for session: " + sessionID)
return true
}
const state: ModelFallbackState = {
providerID: currentProviderID,
modelID: currentModelID,
fallbackChain,
attemptCount: 0,
pending: true,
}
pendingModelFallbacks.set(sessionID, state)
log("[model-fallback] Set pending fallback for session: " + sessionID + ", agent: " + agentName)
return true
}
/**
* Gets the next fallback model for a session.
* Increments attemptCount each time called.
*/
export function getNextFallback(
sessionID: string,
): { providerID: string; modelID: string; variant?: string } | null {
const state = pendingModelFallbacks.get(sessionID)
if (!state) return null
if (!state.pending) return null
const { fallbackChain } = state
const providerModelsCache = readProviderModelsCache()
const connectedProviders = providerModelsCache?.connected ?? readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
const isReachable = (entry: FallbackEntry): boolean => {
if (!connectedSet) return true
// Gate only on provider connectivity. Provider model lists can be stale/incomplete,
// especially after users manually add models to opencode.json.
return entry.providers.some((p) => connectedSet.has(p))
}
while (state.attemptCount < fallbackChain.length) {
const attemptCount = state.attemptCount
const fallback = fallbackChain[attemptCount]
state.attemptCount++
if (!isReachable(fallback)) {
log("[model-fallback] Skipping unreachable fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model)
continue
}
const providerID = selectFallbackProvider(fallback.providers, state.providerID)
state.pending = false
log("[model-fallback] Using fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model)
return {
providerID,
modelID: fallback.model,
variant: fallback.variant,
}
}
log("[model-fallback] No more fallbacks for session: " + sessionID)
pendingModelFallbacks.delete(sessionID)
return null
}
/**
* Clears the pending fallback for a session.
* Called after fallback is successfully applied.
*/
export function clearPendingModelFallback(sessionID: string): void {
pendingModelFallbacks.delete(sessionID)
lastToastKey.delete(sessionID)
}
/**
* Checks if there's a pending fallback for a session.
*/
export function hasPendingModelFallback(sessionID: string): boolean {
const state = pendingModelFallbacks.get(sessionID)
return state?.pending === true
}
/**
* Gets the current fallback state for a session (for debugging).
*/
export function getFallbackState(sessionID: string): ModelFallbackState | undefined {
return pendingModelFallbacks.get(sessionID)
}
/**
* Creates a chat.message hook that applies model fallbacks when pending.
*/
export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplied?: FallbackCallback }) {
const toast = args?.toast
const onApplied = args?.onApplied
return {
"chat.message": async (
input: ChatMessageInput,
output: ChatMessageHandlerOutput,
): Promise<void> => {
const { sessionID } = input
if (!sessionID) return
const fallback = getNextFallback(sessionID)
if (!fallback) return
output.message["model"] = {
providerID: fallback.providerID,
modelID: fallback.modelID,
}
if (fallback.variant !== undefined) {
output.message["variant"] = fallback.variant
} else {
delete output.message["variant"]
}
if (toast) {
const key = `${sessionID}:${fallback.providerID}/${fallback.modelID}:${fallback.variant ?? ""}`
if (lastToastKey.get(sessionID) !== key) {
lastToastKey.set(sessionID, key)
const variantLabel = fallback.variant ? ` (${fallback.variant})` : ""
await Promise.resolve(
toast({
title: "Model fallback",
message: `Using ${fallback.providerID}/${fallback.modelID}${variantLabel}`,
variant: "warning",
duration: 5000,
}),
)
}
}
if (onApplied) {
await Promise.resolve(
onApplied({
sessionID,
providerID: fallback.providerID,
modelID: fallback.modelID,
variant: fallback.variant,
}),
)
}
const toastManager = getTaskToastManager()
if (toastManager) {
const variantLabel = fallback.variant ? ` (${fallback.variant})` : ""
toastManager.updateTaskModelBySession(sessionID, {
model: `${fallback.providerID}/${fallback.modelID}${variantLabel}`,
type: "runtime-fallback",
})
}
log("[model-fallback] Applied fallback model: " + JSON.stringify(fallback))
},
}
}

View File

@@ -0,0 +1,54 @@
import { getSessionAgent } from "../../features/claude-code-session-state"
export const AGENT_NAMES = [
"sisyphus",
"oracle",
"librarian",
"explore",
"prometheus",
"atlas",
"metis",
"momus",
"hephaestus",
"sisyphus-junior",
"build",
"plan",
"multimodal-looker",
]
export const agentPattern = new RegExp(
`\\b(${AGENT_NAMES
.sort((a, b) => b.length - a.length)
.map((a) => a.replace(/-/g, "\\-"))
.join("|")})\\b`,
"i",
)
export function detectAgentFromSession(sessionID: string): string | undefined {
const match = sessionID.match(agentPattern)
if (match) {
return match[1].toLowerCase()
}
return undefined
}
export function normalizeAgentName(agent: string | undefined): string | undefined {
if (!agent) return undefined
const normalized = agent.toLowerCase().trim()
if (AGENT_NAMES.includes(normalized)) {
return normalized
}
const match = normalized.match(agentPattern)
if (match) {
return match[1].toLowerCase()
}
return undefined
}
export function resolveAgentForSession(sessionID: string, eventAgent?: string): string | undefined {
return (
normalizeAgentName(eventAgent) ??
normalizeAgentName(getSessionAgent(sessionID)) ??
detectAgentFromSession(sessionID)
)
}

View File

@@ -0,0 +1,213 @@
import type { HookDeps } from "./types"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { normalizeAgentName, resolveAgentForSession } from "./agent-resolver"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { getFallbackModelsForSession } from "./fallback-models"
import { prepareFallback } from "./fallback-state"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
const SESSION_TTL_MS = 30 * 60 * 1000
export function createAutoRetryHelpers(deps: HookDeps) {
const { ctx, config, options, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, pluginConfig } = deps
const abortSessionRequest = async (sessionID: string, source: string): Promise<void> => {
try {
await ctx.client.session.abort({ path: { id: sessionID } })
log(`[${HOOK_NAME}] Aborted in-flight session request (${source})`, { sessionID })
} catch (error) {
log(`[${HOOK_NAME}] Failed to abort in-flight session request (${source})`, {
sessionID,
error: String(error),
})
}
}
const clearSessionFallbackTimeout = (sessionID: string) => {
const timer = sessionFallbackTimeouts.get(sessionID)
if (timer) {
clearTimeout(timer)
sessionFallbackTimeouts.delete(sessionID)
}
}
const scheduleSessionFallbackTimeout = (sessionID: string, resolvedAgent?: string) => {
clearSessionFallbackTimeout(sessionID)
const timeoutMs = options?.session_timeout_ms ?? config.timeout_seconds * 1000
if (timeoutMs <= 0) return
const timer = setTimeout(async () => {
sessionFallbackTimeouts.delete(sessionID)
const state = sessionStates.get(sessionID)
if (!state) return
if (sessionRetryInFlight.has(sessionID)) {
log(`[${HOOK_NAME}] Overriding in-flight retry due to session timeout`, { sessionID })
}
await abortSessionRequest(sessionID, "session.timeout")
sessionRetryInFlight.delete(sessionID)
if (state.pendingFallbackModel) {
state.pendingFallbackModel = undefined
}
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
if (fallbackModels.length === 0) return
log(`[${HOOK_NAME}] Session fallback timeout reached`, {
sessionID,
timeoutSeconds: config.timeout_seconds,
currentModel: state.currentModel,
})
const result = prepareFallback(sessionID, state, fallbackModels, config)
if (result.success && result.newModel) {
await autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "session.timeout")
}
}, timeoutMs)
sessionFallbackTimeouts.set(sessionID, timer)
}
const autoRetryWithFallback = async (
sessionID: string,
newModel: string,
resolvedAgent: string | undefined,
source: string,
): Promise<void> => {
if (sessionRetryInFlight.has(sessionID)) {
log(`[${HOOK_NAME}] Retry already in flight, skipping (${source})`, { sessionID })
return
}
const modelParts = newModel.split("/")
if (modelParts.length < 2) {
log(`[${HOOK_NAME}] Invalid model format (missing provider prefix): ${newModel}`)
return
}
const fallbackModelObj = {
providerID: modelParts[0],
modelID: modelParts.slice(1).join("/"),
}
sessionRetryInFlight.add(sessionID)
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const msgs = (messagesResp as {
data?: Array<{
info?: Record<string, unknown>
parts?: Array<{ type?: string; text?: string }>
}>
}).data
const lastUserMsg = msgs?.filter((m) => m.info?.role === "user").pop()
const lastUserPartsRaw =
lastUserMsg?.parts ??
(lastUserMsg?.info?.parts as Array<{ type?: string; text?: string }> | undefined)
if (lastUserPartsRaw && lastUserPartsRaw.length > 0) {
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
sessionID,
model: newModel,
})
const retryParts = lastUserPartsRaw
.filter((p) => p.type === "text" && typeof p.text === "string" && p.text.length > 0)
.map((p) => ({ type: "text" as const, text: p.text! }))
if (retryParts.length > 0) {
const retryAgent = resolvedAgent ?? getSessionAgent(sessionID)
sessionAwaitingFallbackResult.add(sessionID)
scheduleSessionFallbackTimeout(sessionID, retryAgent)
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
...(retryAgent ? { agent: retryAgent } : {}),
model: fallbackModelObj,
parts: retryParts,
},
query: { directory: ctx.directory },
})
}
} else {
log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID })
}
} catch (retryError) {
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
} finally {
const state = sessionStates.get(sessionID)
if (state?.pendingFallbackModel === newModel) {
state.pendingFallbackModel = undefined
}
sessionRetryInFlight.delete(sessionID)
}
}
const resolveAgentForSessionFromContext = async (
sessionID: string,
eventAgent?: string,
): Promise<string | undefined> => {
const resolved = resolveAgentForSession(sessionID, eventAgent)
if (resolved) return resolved
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const msgs = (messagesResp as { data?: Array<{ info?: Record<string, unknown> }> }).data
if (!msgs || msgs.length === 0) return undefined
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i]?.info
const infoAgent = typeof info?.agent === "string" ? info.agent : undefined
const normalized = normalizeAgentName(infoAgent)
if (normalized) {
return normalized
}
}
} catch {
return undefined
}
return undefined
}
const cleanupStaleSessions = () => {
const now = Date.now()
let cleanedCount = 0
for (const [sessionID, lastAccess] of sessionLastAccess.entries()) {
if (now - lastAccess > SESSION_TTL_MS) {
sessionStates.delete(sessionID)
sessionLastAccess.delete(sessionID)
sessionRetryInFlight.delete(sessionID)
sessionAwaitingFallbackResult.delete(sessionID)
clearSessionFallbackTimeout(sessionID)
SessionCategoryRegistry.remove(sessionID)
cleanedCount++
}
}
if (cleanedCount > 0) {
log(`[${HOOK_NAME}] Cleaned up ${cleanedCount} stale session states`)
}
}
return {
abortSessionRequest,
clearSessionFallbackTimeout,
scheduleSessionFallbackTimeout,
autoRetryWithFallback,
resolveAgentForSessionFromContext,
cleanupStaleSessions,
}
}
export type AutoRetryHelpers = ReturnType<typeof createAutoRetryHelpers>

View File

@@ -0,0 +1,62 @@
import type { HookDeps } from "./types"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { createFallbackState } from "./fallback-state"
export function createChatMessageHandler(deps: HookDeps) {
const { config, sessionStates, sessionLastAccess } = deps
return async (
input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string } },
output: { message: { model?: { providerID: string; modelID: string } }; parts?: Array<{ type: string; text?: string }> }
) => {
if (!config.enabled) return
const { sessionID } = input
let state = sessionStates.get(sessionID)
if (!state) return
sessionLastAccess.set(sessionID, Date.now())
const requestedModel = input.model
? `${input.model.providerID}/${input.model.modelID}`
: undefined
if (requestedModel && requestedModel !== state.currentModel) {
if (state.pendingFallbackModel && state.pendingFallbackModel === requestedModel) {
state.pendingFallbackModel = undefined
return
}
log(`[${HOOK_NAME}] Detected manual model change, resetting fallback state`, {
sessionID,
from: state.currentModel,
to: requestedModel,
})
state = createFallbackState(requestedModel)
sessionStates.set(sessionID, state)
return
}
if (state.currentModel === state.originalModel) return
const activeModel = state.currentModel
log(`[${HOOK_NAME}] Applying fallback model override`, {
sessionID,
from: input.model,
to: activeModel,
})
if (output.message && activeModel) {
const parts = activeModel.split("/")
if (parts.length >= 2) {
output.message.model = {
providerID: parts[0],
modelID: parts.slice(1).join("/"),
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
/**
* Runtime Fallback Hook - Constants
*
* Default values and configuration constants for the runtime fallback feature.
*/
import type { RuntimeFallbackConfig } from "../../config"
/**
* Default configuration values for runtime fallback
*/
export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
enabled: true,
retry_on_errors: [400, 429, 503, 529],
max_fallback_attempts: 3,
cooldown_seconds: 60,
timeout_seconds: 30,
notify_on_fallback: true,
}
/**
* Error patterns that indicate rate limiting or temporary failures
* These are checked in addition to HTTP status codes
*/
export const RETRYABLE_ERROR_PATTERNS = [
/rate.?limit/i,
/too.?many.?requests/i,
/quota.?exceeded/i,
/usage\s+limit\s+has\s+been\s+reached/i,
/service.?unavailable/i,
/overloaded/i,
/temporarily.?unavailable/i,
/try.?again/i,
/credit.*balance.*too.*low/i,
/insufficient.?(?:credits?|funds?|balance)/i,
/(?:^|\s)429(?:\s|$)/,
/(?:^|\s)503(?:\s|$)/,
/(?:^|\s)529(?:\s|$)/,
]
/**
* Hook name for identification and logging
*/
export const HOOK_NAME = "runtime-fallback"

View File

@@ -0,0 +1,169 @@
import { DEFAULT_CONFIG, RETRYABLE_ERROR_PATTERNS } from "./constants"
export function getErrorMessage(error: unknown): string {
if (!error) return ""
if (typeof error === "string") return error.toLowerCase()
const errorObj = error as Record<string, unknown>
const paths = [
errorObj.data,
errorObj.error,
errorObj,
(errorObj.data as Record<string, unknown>)?.error,
]
for (const obj of paths) {
if (obj && typeof obj === "object") {
const msg = (obj as Record<string, unknown>).message
if (typeof msg === "string" && msg.length > 0) {
return msg.toLowerCase()
}
}
}
try {
return JSON.stringify(error).toLowerCase()
} catch {
return ""
}
}
export function extractStatusCode(error: unknown, retryOnErrors?: number[]): number | undefined {
if (!error) return undefined
const errorObj = error as Record<string, unknown>
const statusCode = errorObj.statusCode ?? errorObj.status ?? (errorObj.data as Record<string, unknown>)?.statusCode
if (typeof statusCode === "number") {
return statusCode
}
const codes = retryOnErrors ?? DEFAULT_CONFIG.retry_on_errors
const pattern = new RegExp(`\\b(${codes.join("|")})\\b`)
const message = getErrorMessage(error)
const statusMatch = message.match(pattern)
if (statusMatch) {
return parseInt(statusMatch[1], 10)
}
return undefined
}
export function extractErrorName(error: unknown): string | undefined {
if (!error || typeof error !== "object") return undefined
const errorObj = error as Record<string, unknown>
const directName = errorObj.name
if (typeof directName === "string" && directName.length > 0) {
return directName
}
const nestedError = errorObj.error as Record<string, unknown> | undefined
const nestedName = nestedError?.name
if (typeof nestedName === "string" && nestedName.length > 0) {
return nestedName
}
const dataError = (errorObj.data as Record<string, unknown> | undefined)?.error as Record<string, unknown> | undefined
const dataErrorName = dataError?.name
if (typeof dataErrorName === "string" && dataErrorName.length > 0) {
return dataErrorName
}
return undefined
}
export function classifyErrorType(error: unknown): string | undefined {
const message = getErrorMessage(error)
const errorName = extractErrorName(error)?.toLowerCase()
if (
errorName?.includes("loadapi") ||
(/api.?key.?is.?missing/i.test(message) && /environment variable/i.test(message))
) {
return "missing_api_key"
}
if (/api.?key/i.test(message) && /must be a string/i.test(message)) {
return "invalid_api_key"
}
if (errorName?.includes("unknownerror") && /model\s+not\s+found/i.test(message)) {
return "model_not_found"
}
return undefined
}
export interface AutoRetrySignal {
signal: string
}
export const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [
(combined) => /retrying\s+in/i.test(combined),
(combined) =>
/(?:too\s+many\s+requests|quota\s*exceeded|usage\s+limit|rate\s+limit|limit\s+reached)/i.test(combined),
]
export function extractAutoRetrySignal(info: Record<string, unknown> | undefined): AutoRetrySignal | undefined {
if (!info) return undefined
const candidates: string[] = []
const directStatus = info.status
if (typeof directStatus === "string") candidates.push(directStatus)
const summary = info.summary
if (typeof summary === "string") candidates.push(summary)
const message = info.message
if (typeof message === "string") candidates.push(message)
const details = info.details
if (typeof details === "string") candidates.push(details)
const combined = candidates.join("\n")
if (!combined) return undefined
const isAutoRetry = AUTO_RETRY_PATTERNS.every((test) => test(combined))
if (isAutoRetry) {
return { signal: combined }
}
return undefined
}
export function containsErrorContent(
parts: Array<{ type?: string; text?: string }> | undefined
): { hasError: boolean; errorMessage?: string } {
if (!parts || parts.length === 0) return { hasError: false }
const errorParts = parts.filter((p) => p.type === "error")
if (errorParts.length > 0) {
const errorMessages = errorParts.map((p) => p.text).filter((text): text is string => typeof text === "string")
const errorMessage = errorMessages.length > 0 ? errorMessages.join("\n") : undefined
return { hasError: true, errorMessage }
}
return { hasError: false }
}
export function isRetryableError(error: unknown, retryOnErrors: number[]): boolean {
const statusCode = extractStatusCode(error, retryOnErrors)
const message = getErrorMessage(error)
const errorType = classifyErrorType(error)
if (errorType === "missing_api_key") {
return true
}
if (errorType === "model_not_found") {
return true
}
if (statusCode && retryOnErrors.includes(statusCode)) {
return true
}
return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(message))
}

View File

@@ -0,0 +1,187 @@
import type { HookDeps } from "./types"
import type { AutoRetryHelpers } from "./auto-retry"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError } from "./error-classifier"
import { createFallbackState, prepareFallback } from "./fallback-state"
import { getFallbackModelsForSession } from "./fallback-models"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts } = deps
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
const sessionID = sessionInfo?.id
const model = sessionInfo?.model
if (sessionID && model) {
log(`[${HOOK_NAME}] Session created with model`, { sessionID, model })
sessionStates.set(sessionID, createFallbackState(model))
sessionLastAccess.set(sessionID, Date.now())
}
}
const handleSessionDeleted = (props: Record<string, unknown> | undefined) => {
const sessionInfo = props?.info as { id?: string } | undefined
const sessionID = sessionInfo?.id
if (sessionID) {
log(`[${HOOK_NAME}] Cleaning up session state`, { sessionID })
sessionStates.delete(sessionID)
sessionLastAccess.delete(sessionID)
sessionRetryInFlight.delete(sessionID)
sessionAwaitingFallbackResult.delete(sessionID)
helpers.clearSessionFallbackTimeout(sessionID)
SessionCategoryRegistry.remove(sessionID)
}
}
const handleSessionStop = async (props: Record<string, unknown> | undefined) => {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
helpers.clearSessionFallbackTimeout(sessionID)
if (sessionRetryInFlight.has(sessionID)) {
await helpers.abortSessionRequest(sessionID, "session.stop")
}
sessionRetryInFlight.delete(sessionID)
sessionAwaitingFallbackResult.delete(sessionID)
const state = sessionStates.get(sessionID)
if (state?.pendingFallbackModel) {
state.pendingFallbackModel = undefined
}
log(`[${HOOK_NAME}] Cleared fallback retry state on session.stop`, { sessionID })
}
const handleSessionIdle = (props: Record<string, unknown> | undefined) => {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
if (sessionAwaitingFallbackResult.has(sessionID)) {
log(`[${HOOK_NAME}] session.idle while awaiting fallback result; keeping timeout armed`, { sessionID })
return
}
const hadTimeout = sessionFallbackTimeouts.has(sessionID)
helpers.clearSessionFallbackTimeout(sessionID)
sessionRetryInFlight.delete(sessionID)
const state = sessionStates.get(sessionID)
if (state?.pendingFallbackModel) {
state.pendingFallbackModel = undefined
}
if (hadTimeout) {
log(`[${HOOK_NAME}] Cleared fallback timeout after session completion`, { sessionID })
}
}
const handleSessionError = async (props: Record<string, unknown> | undefined) => {
const sessionID = props?.sessionID as string | undefined
const error = props?.error
const agent = props?.agent as string | undefined
if (!sessionID) {
log(`[${HOOK_NAME}] session.error without sessionID, skipping`)
return
}
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
sessionAwaitingFallbackResult.delete(sessionID)
helpers.clearSessionFallbackTimeout(sessionID)
log(`[${HOOK_NAME}] session.error received`, {
sessionID,
agent,
resolvedAgent,
statusCode: extractStatusCode(error, config.retry_on_errors),
errorName: extractErrorName(error),
errorType: classifyErrorType(error),
})
if (!isRetryableError(error, config.retry_on_errors)) {
log(`[${HOOK_NAME}] Error not retryable, skipping fallback`, {
sessionID,
retryable: false,
statusCode: extractStatusCode(error, config.retry_on_errors),
errorName: extractErrorName(error),
errorType: classifyErrorType(error),
})
return
}
let state = sessionStates.get(sessionID)
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
if (fallbackModels.length === 0) {
log(`[${HOOK_NAME}] No fallback models configured`, { sessionID, agent })
return
}
if (!state) {
const currentModel = props?.model as string | undefined
if (currentModel) {
state = createFallbackState(currentModel)
sessionStates.set(sessionID, state)
sessionLastAccess.set(sessionID, Date.now())
} else {
const detectedAgent = resolvedAgent
const agentConfig = detectedAgent
? pluginConfig?.agents?.[detectedAgent as keyof typeof pluginConfig.agents]
: undefined
const agentModel = agentConfig?.model as string | undefined
if (agentModel) {
log(`[${HOOK_NAME}] Derived model from agent config`, { sessionID, agent: detectedAgent, model: agentModel })
state = createFallbackState(agentModel)
sessionStates.set(sessionID, state)
sessionLastAccess.set(sessionID, Date.now())
} else {
log(`[${HOOK_NAME}] No model info available, cannot fallback`, { sessionID })
return
}
}
} else {
sessionLastAccess.set(sessionID, Date.now())
}
const result = prepareFallback(sessionID, state, fallbackModels, config)
if (result.success && config.notify_on_fallback) {
await deps.ctx.client.tui
.showToast({
body: {
title: "Model Fallback",
message: `Switching to ${result.newModel?.split("/").pop() || result.newModel} for next request`,
variant: "warning",
duration: 5000,
},
})
.catch(() => {})
}
if (result.success && result.newModel) {
await helpers.autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "session.error")
}
if (!result.success) {
log(`[${HOOK_NAME}] Fallback preparation failed`, { sessionID, error: result.error })
}
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (!config.enabled) return
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.created") { handleSessionCreated(props); return }
if (event.type === "session.deleted") { handleSessionDeleted(props); return }
if (event.type === "session.stop") { await handleSessionStop(props); return }
if (event.type === "session.idle") { handleSessionIdle(props); return }
if (event.type === "session.error") { await handleSessionError(props); return }
}
}

View File

@@ -0,0 +1,69 @@
import type { OhMyOpenCodeConfig } from "../../config"
import { AGENT_NAMES, agentPattern } from "./agent-resolver"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { normalizeFallbackModels } from "../../shared/model-resolver"
export function getFallbackModelsForSession(
sessionID: string,
agent: string | undefined,
pluginConfig: OhMyOpenCodeConfig | undefined
): string[] {
if (!pluginConfig) return []
const sessionCategory = SessionCategoryRegistry.get(sessionID)
if (sessionCategory && pluginConfig.categories?.[sessionCategory]) {
const categoryConfig = pluginConfig.categories[sessionCategory]
if (categoryConfig?.fallback_models) {
return normalizeFallbackModels(categoryConfig.fallback_models) ?? []
}
}
const tryGetFallbackFromAgent = (agentName: string): string[] | undefined => {
const agentConfig = pluginConfig.agents?.[agentName as keyof typeof pluginConfig.agents]
if (!agentConfig) return undefined
if (agentConfig?.fallback_models) {
return normalizeFallbackModels(agentConfig.fallback_models)
}
const agentCategory = agentConfig?.category
if (agentCategory && pluginConfig.categories?.[agentCategory]) {
const categoryConfig = pluginConfig.categories[agentCategory]
if (categoryConfig?.fallback_models) {
return normalizeFallbackModels(categoryConfig.fallback_models)
}
}
return undefined
}
if (agent) {
const result = tryGetFallbackFromAgent(agent)
if (result) return result
}
const sessionAgentMatch = sessionID.match(agentPattern)
if (sessionAgentMatch) {
const detectedAgent = sessionAgentMatch[1].toLowerCase()
const result = tryGetFallbackFromAgent(detectedAgent)
if (result) return result
}
const sisyphusFallback = tryGetFallbackFromAgent("sisyphus")
if (sisyphusFallback) {
log(`[${HOOK_NAME}] Using sisyphus fallback models (no agent detected)`, { sessionID })
return sisyphusFallback
}
for (const agentName of AGENT_NAMES) {
const result = tryGetFallbackFromAgent(agentName)
if (result) {
log(`[${HOOK_NAME}] Using ${agentName} fallback models (no agent detected)`, { sessionID })
return result
}
}
return []
}

View File

@@ -0,0 +1,74 @@
import type { FallbackState, FallbackResult } from "./types"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import type { RuntimeFallbackConfig } from "../../config"
export function createFallbackState(originalModel: string): FallbackState {
return {
originalModel,
currentModel: originalModel,
fallbackIndex: -1,
failedModels: new Map<string, number>(),
attemptCount: 0,
pendingFallbackModel: undefined,
}
}
export function isModelInCooldown(model: string, state: FallbackState, cooldownSeconds: number): boolean {
const failedAt = state.failedModels.get(model)
if (failedAt === undefined) return false
const cooldownMs = cooldownSeconds * 1000
return Date.now() - failedAt < cooldownMs
}
export function findNextAvailableFallback(
state: FallbackState,
fallbackModels: string[],
cooldownSeconds: number
): string | undefined {
for (let i = state.fallbackIndex + 1; i < fallbackModels.length; i++) {
const candidate = fallbackModels[i]
if (!isModelInCooldown(candidate, state, cooldownSeconds)) {
return candidate
}
log(`[${HOOK_NAME}] Skipping fallback model in cooldown`, { model: candidate, index: i })
}
return undefined
}
export function prepareFallback(
sessionID: string,
state: FallbackState,
fallbackModels: string[],
config: Required<RuntimeFallbackConfig>
): FallbackResult {
if (state.attemptCount >= config.max_fallback_attempts) {
log(`[${HOOK_NAME}] Max fallback attempts reached`, { sessionID, attempts: state.attemptCount })
return { success: false, error: "Max fallback attempts reached", maxAttemptsReached: true }
}
const nextModel = findNextAvailableFallback(state, fallbackModels, config.cooldown_seconds)
if (!nextModel) {
log(`[${HOOK_NAME}] No available fallback models`, { sessionID })
return { success: false, error: "No available fallback models (all in cooldown or exhausted)" }
}
log(`[${HOOK_NAME}] Preparing fallback`, {
sessionID,
from: state.currentModel,
to: nextModel,
attempt: state.attemptCount + 1,
})
const failedModel = state.currentModel
const now = Date.now()
state.fallbackIndex = fallbackModels.indexOf(nextModel)
state.failedModels.set(failedModel, now)
state.attemptCount++
state.currentModel = nextModel
state.pendingFallbackModel = nextModel
return { success: true, newModel: nextModel }
}

View File

@@ -0,0 +1,67 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { HookDeps, RuntimeFallbackHook, RuntimeFallbackOptions } from "./types"
import { DEFAULT_CONFIG, HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { loadPluginConfig } from "../../plugin-config"
import { createAutoRetryHelpers } from "./auto-retry"
import { createEventHandler } from "./event-handler"
import { createMessageUpdateHandler } from "./message-update-handler"
import { createChatMessageHandler } from "./chat-message-handler"
export function createRuntimeFallbackHook(
ctx: PluginInput,
options?: RuntimeFallbackOptions
): RuntimeFallbackHook {
const config = {
enabled: options?.config?.enabled ?? DEFAULT_CONFIG.enabled,
retry_on_errors: options?.config?.retry_on_errors ?? DEFAULT_CONFIG.retry_on_errors,
max_fallback_attempts: options?.config?.max_fallback_attempts ?? DEFAULT_CONFIG.max_fallback_attempts,
cooldown_seconds: options?.config?.cooldown_seconds ?? DEFAULT_CONFIG.cooldown_seconds,
timeout_seconds: options?.config?.timeout_seconds ?? DEFAULT_CONFIG.timeout_seconds,
notify_on_fallback: options?.config?.notify_on_fallback ?? DEFAULT_CONFIG.notify_on_fallback,
}
let pluginConfig = options?.pluginConfig
if (!pluginConfig) {
try {
pluginConfig = loadPluginConfig(ctx.directory, ctx)
} catch {
log(`[${HOOK_NAME}] Plugin config not available`)
}
}
const deps: HookDeps = {
ctx,
config,
options,
pluginConfig,
sessionStates: new Map(),
sessionLastAccess: new Map(),
sessionRetryInFlight: new Set(),
sessionAwaitingFallbackResult: new Set(),
sessionFallbackTimeouts: new Map(),
}
const helpers = createAutoRetryHelpers(deps)
const baseEventHandler = createEventHandler(deps, helpers)
const messageUpdateHandler = createMessageUpdateHandler(deps, helpers)
const chatMessageHandler = createChatMessageHandler(deps)
const cleanupInterval = setInterval(helpers.cleanupStaleSessions, 5 * 60 * 1000)
cleanupInterval.unref()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type === "message.updated") {
if (!config.enabled) return
const props = event.properties as Record<string, unknown> | undefined
await messageUpdateHandler(props)
return
}
await baseEventHandler({ event })
}
return {
event: eventHandler,
"chat.message": chatMessageHandler,
} as RuntimeFallbackHook
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
export { createRuntimeFallbackHook } from "./hook"
export type { RuntimeFallbackHook, RuntimeFallbackOptions } from "./types"

View File

@@ -0,0 +1,216 @@
import type { HookDeps } from "./types"
import type { AutoRetryHelpers } from "./auto-retry"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError, extractAutoRetrySignal, containsErrorContent } from "./error-classifier"
import { createFallbackState, prepareFallback } from "./fallback-state"
import { getFallbackModelsForSession } from "./fallback-models"
export function hasVisibleAssistantResponse(extractAutoRetrySignalFn: typeof extractAutoRetrySignal) {
return async (
ctx: HookDeps["ctx"],
sessionID: string,
_info: Record<string, unknown> | undefined,
): Promise<boolean> => {
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const msgs = (messagesResp as {
data?: Array<{
info?: Record<string, unknown>
parts?: Array<{ type?: string; text?: string }>
}>
}).data
if (!msgs || msgs.length === 0) return false
const lastAssistant = [...msgs].reverse().find((m) => m.info?.role === "assistant")
if (!lastAssistant) return false
if (lastAssistant.info?.error) return false
const parts = lastAssistant.parts ??
(lastAssistant.info?.parts as Array<{ type?: string; text?: string }> | undefined)
const textFromParts = (parts ?? [])
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text!.trim())
.filter((text) => text.length > 0)
.join("\n")
if (!textFromParts) return false
if (extractAutoRetrySignalFn({ message: textFromParts })) return false
return true
} catch {
return false
}
}
}
export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
const { ctx, config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult } = deps
const checkVisibleResponse = hasVisibleAssistantResponse(extractAutoRetrySignal)
return async (props: Record<string, unknown> | undefined) => {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const retrySignalResult = extractAutoRetrySignal(info)
const retrySignal = retrySignalResult?.signal
const timeoutEnabled = config.timeout_seconds > 0
const parts = props?.parts as Array<{ type?: string; text?: string }> | undefined
const errorContentResult = containsErrorContent(parts)
const error = info?.error ??
(retrySignal && timeoutEnabled ? { name: "ProviderRateLimitError", message: retrySignal } : undefined) ??
(errorContentResult.hasError ? { name: "MessageContentError", message: errorContentResult.errorMessage || "Message contains error content" } : undefined)
const role = info?.role as string | undefined
const model = info?.model as string | undefined
if (sessionID && role === "assistant" && !error) {
if (!sessionAwaitingFallbackResult.has(sessionID)) {
return
}
const hasVisible = await checkVisibleResponse(ctx, sessionID, info)
if (!hasVisible) {
log(`[${HOOK_NAME}] Assistant update observed without visible final response; keeping fallback timeout`, {
sessionID,
model,
})
return
}
sessionAwaitingFallbackResult.delete(sessionID)
helpers.clearSessionFallbackTimeout(sessionID)
const state = sessionStates.get(sessionID)
if (state?.pendingFallbackModel) {
state.pendingFallbackModel = undefined
}
log(`[${HOOK_NAME}] Assistant response observed; cleared fallback timeout`, { sessionID, model })
return
}
if (sessionID && role === "assistant" && error) {
sessionAwaitingFallbackResult.delete(sessionID)
if (sessionRetryInFlight.has(sessionID) && !retrySignal) {
log(`[${HOOK_NAME}] message.updated fallback skipped (retry in flight)`, { sessionID })
return
}
if (retrySignal && sessionRetryInFlight.has(sessionID) && timeoutEnabled) {
log(`[${HOOK_NAME}] Overriding in-flight retry due to provider auto-retry signal`, {
sessionID,
model,
})
await helpers.abortSessionRequest(sessionID, "message.updated.retry-signal")
sessionRetryInFlight.delete(sessionID)
}
if (retrySignal && timeoutEnabled) {
log(`[${HOOK_NAME}] Detected provider auto-retry signal`, { sessionID, model })
}
if (!retrySignal) {
helpers.clearSessionFallbackTimeout(sessionID)
}
log(`[${HOOK_NAME}] message.updated with assistant error`, {
sessionID,
model,
statusCode: extractStatusCode(error, config.retry_on_errors),
errorName: extractErrorName(error),
errorType: classifyErrorType(error),
})
if (!isRetryableError(error, config.retry_on_errors)) {
log(`[${HOOK_NAME}] message.updated error not retryable, skipping fallback`, {
sessionID,
statusCode: extractStatusCode(error, config.retry_on_errors),
errorName: extractErrorName(error),
errorType: classifyErrorType(error),
})
return
}
let state = sessionStates.get(sessionID)
const agent = info?.agent as string | undefined
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
if (fallbackModels.length === 0) {
return
}
if (!state) {
let initialModel = model
if (!initialModel) {
const detectedAgent = resolvedAgent
const agentConfig = detectedAgent
? pluginConfig?.agents?.[detectedAgent as keyof typeof pluginConfig.agents]
: undefined
const agentModel = agentConfig?.model as string | undefined
if (agentModel) {
log(`[${HOOK_NAME}] Derived model from agent config for message.updated`, {
sessionID,
agent: detectedAgent,
model: agentModel,
})
initialModel = agentModel
}
}
if (!initialModel) {
log(`[${HOOK_NAME}] message.updated missing model info, cannot fallback`, {
sessionID,
errorName: extractErrorName(error),
errorType: classifyErrorType(error),
})
return
}
state = createFallbackState(initialModel)
sessionStates.set(sessionID, state)
sessionLastAccess.set(sessionID, Date.now())
} else {
sessionLastAccess.set(sessionID, Date.now())
if (state.pendingFallbackModel) {
if (retrySignal && timeoutEnabled) {
log(`[${HOOK_NAME}] Clearing pending fallback due to provider auto-retry signal`, {
sessionID,
pendingFallbackModel: state.pendingFallbackModel,
})
state.pendingFallbackModel = undefined
} else {
log(`[${HOOK_NAME}] message.updated fallback skipped (pending fallback in progress)`, {
sessionID,
pendingFallbackModel: state.pendingFallbackModel,
})
return
}
}
}
const result = prepareFallback(sessionID, state, fallbackModels, config)
if (result.success && config.notify_on_fallback) {
await deps.ctx.client.tui
.showToast({
body: {
title: "Model Fallback",
message: `Switching to ${result.newModel?.split("/").pop() || result.newModel} for next request`,
variant: "warning",
duration: 5000,
},
})
.catch(() => {})
}
if (result.success && result.newModel) {
await helpers.autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "message.updated")
}
}
}
}

View File

@@ -0,0 +1,41 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
export interface FallbackState {
originalModel: string
currentModel: string
fallbackIndex: number
failedModels: Map<string, number>
attemptCount: number
pendingFallbackModel?: string
}
export interface FallbackResult {
success: boolean
newModel?: string
error?: string
maxAttemptsReached?: boolean
}
export interface RuntimeFallbackOptions {
config?: RuntimeFallbackConfig
pluginConfig?: OhMyOpenCodeConfig
session_timeout_ms?: number
}
export interface RuntimeFallbackHook {
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
"chat.message"?: (input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string } }, output: { message: { model?: { providerID: string; modelID: string } }; parts?: Array<{ type: string; text?: string }> }) => Promise<void>
}
export interface HookDeps {
ctx: PluginInput
config: Required<RuntimeFallbackConfig>
options: RuntimeFallbackOptions | undefined
pluginConfig: OhMyOpenCodeConfig | undefined
sessionStates: Map<string, FallbackState>
sessionLastAccess: Map<string, number>
sessionRetryInFlight: Set<string>
sessionAwaitingFallbackResult: Set<string>
sessionFallbackTimeouts: Map<string, ReturnType<typeof setTimeout>>
}

View File

@@ -5,7 +5,6 @@ import { createChatParamsHandler } from "./plugin/chat-params"
import { createChatHeadersHandler } from "./plugin/chat-headers"
import { createChatMessageHandler } from "./plugin/chat-message"
import { createMessagesTransformHandler } from "./plugin/messages-transform"
import { createSystemTransformHandler } from "./plugin/system-transform"
import { createEventHandler } from "./plugin/event"
import { createToolExecuteAfterHandler } from "./plugin/tool-execute-after"
import { createToolExecuteBeforeHandler } from "./plugin/tool-execute-before"
@@ -50,10 +49,6 @@ export function createPluginInterface(args: {
hooks,
}),
"experimental.chat.system.transform": createSystemTransformHandler({
hooks,
}),
config: managers.configHandler,
event: createEventHandler({

View File

@@ -2,7 +2,6 @@ import type { OhMyOpenCodeConfig } from "../config"
import type { PluginContext } from "./types"
import { hasConnectedProvidersCache } from "../shared"
import { setSessionModel } from "../shared/session-model-state"
import { setSessionAgent } from "../features/claude-code-session-state"
import { applyUltraworkModelOverrideOnMessage } from "./ultrawork-model-override"
@@ -14,12 +13,7 @@ type FirstMessageVariantGate = {
}
type ChatMessagePart = { type: string; text?: string; [key: string]: unknown }
export type ChatMessageHandlerOutput = { message: Record<string, unknown>; parts: ChatMessagePart[] }
export type ChatMessageInput = {
sessionID: string
agent?: string
model?: { providerID: string; modelID: string }
}
type ChatMessageHandlerOutput = { message: Record<string, unknown>; parts: ChatMessagePart[] }
type StartWorkHookOutput = { parts: Array<{ type: string; text?: string }> }
function isStartWorkHookOutput(value: unknown): value is StartWorkHookOutput {
@@ -40,13 +34,13 @@ export function createChatMessageHandler(args: {
firstMessageVariantGate: FirstMessageVariantGate
hooks: CreatedHooks
}): (
input: ChatMessageInput,
input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string } },
output: ChatMessageHandlerOutput
) => Promise<void> {
const { ctx, pluginConfig, firstMessageVariantGate, hooks } = args
return async (
input: ChatMessageInput,
input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string } },
output: ChatMessageHandlerOutput
): Promise<void> => {
if (input.agent) {
@@ -59,23 +53,8 @@ export function createChatMessageHandler(args: {
firstMessageVariantGate.markApplied(input.sessionID)
}
await hooks.modelFallback?.["chat.message"]?.(input, output)
const modelOverride = output.message["model"]
if (
modelOverride &&
typeof modelOverride === "object" &&
"providerID" in modelOverride &&
"modelID" in modelOverride
) {
const providerID = (modelOverride as { providerID?: string }).providerID
const modelID = (modelOverride as { modelID?: string }).modelID
if (typeof providerID === "string" && typeof modelID === "string") {
setSessionModel(input.sessionID, { providerID, modelID })
}
} else if (input.model) {
setSessionModel(input.sessionID, input.model)
}
await hooks.stopContinuationGuard?.["chat.message"]?.(input)
await hooks.runtimeFallback?.["chat.message"]?.(input, output)
await hooks.keywordDetector?.["chat.message"]?.(input, output)
await hooks.claudeCodeHooks?.["chat.message"]?.(input, output)
await hooks.autoSlashCommand?.["chat.message"]?.(input, output)

View File

@@ -1,38 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createChatParamsHandler } from "./chat-params"
describe("createChatParamsHandler", () => {
test("normalizes object-style agent payload and runs chat.params hooks", async () => {
//#given
let called = false
const handler = createChatParamsHandler({
anthropicEffort: {
"chat.params": async (input) => {
called = input.agent.name === "sisyphus"
},
},
})
const input = {
sessionID: "ses_chat_params",
agent: { name: "sisyphus" },
model: { providerID: "opencode", modelID: "claude-opus-4-6" },
provider: { id: "opencode" },
message: {},
}
const output = {
temperature: 0.1,
topP: 1,
topK: 1,
options: {},
}
//#when
await handler(input, output)
//#then
expect(called).toBe(true)
})
})

View File

@@ -1,4 +1,4 @@
export type ChatParamsInput = {
type ChatParamsInput = {
sessionID: string
agent: { name?: string }
model: { providerID: string; modelID: string }
@@ -6,7 +6,7 @@ export type ChatParamsInput = {
message: { variant?: string }
}
export type ChatParamsOutput = {
type ChatParamsOutput = {
temperature?: number
topP?: number
topK?: number
@@ -27,21 +27,11 @@ function buildChatParamsInput(raw: unknown): ChatParamsInput | null {
const message = raw.message
if (typeof sessionID !== "string") return null
if (typeof agent !== "string") return null
if (!isRecord(model)) return null
if (!isRecord(provider)) return null
if (!isRecord(message)) return null
let agentName: string | undefined
if (typeof agent === "string") {
agentName = agent
} else if (isRecord(agent)) {
const name = agent.name
if (typeof name === "string") {
agentName = name
}
}
if (!agentName) return null
const providerID = model.providerID
const modelID = model.modelID
const providerId = provider.id
@@ -53,7 +43,7 @@ function buildChatParamsInput(raw: unknown): ChatParamsInput | null {
return {
sessionID,
agent: { name: agentName },
agent: { name: agent },
model: { providerID, modelID },
provider: { id: providerId },
message: typeof variant === "string" ? { variant } : {},

View File

@@ -1,343 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createEventHandler } from "./event"
import { createChatMessageHandler } from "./chat-message"
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook"
describe("createEventHandler - model fallback", () => {
const createHandler = (args?: { hooks?: any }) => {
const abortCalls: string[] = []
const promptCalls: string[] = []
const handler = createEventHandler({
ctx: {
directory: "/tmp",
client: {
session: {
abort: async ({ path }: { path: { id: string } }) => {
abortCalls.push(path.id)
return {}
},
prompt: async ({ path }: { path: { id: string } }) => {
promptCalls.push(path.id)
return {}
},
},
},
} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
skillMcpManager: {
disconnectSession: async () => {},
},
} as any,
hooks: args?.hooks ?? ({} as any),
})
return { handler, abortCalls, promptCalls }
}
afterEach(() => {
_resetForTesting()
})
test("triggers retry prompt for assistant message.updated APIError payloads (headless resume)", async () => {
//#given
const sessionID = "ses_message_updated_fallback"
const { handler, abortCalls, promptCalls } = createHandler()
//#when
await handler({
event: {
type: "message.updated",
properties: {
info: {
id: "msg_err_1",
sessionID,
role: "assistant",
time: { created: 1, completed: 2 },
error: {
name: "APIError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
isRetryable: true,
},
},
parentID: "msg_user_1",
modelID: "claude-opus-4-6-thinking",
providerID: "quotio",
mode: "Sisyphus (Ultraworker)",
agent: "Sisyphus (Ultraworker)",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
},
},
})
//#then
expect(abortCalls).toEqual([sessionID])
expect(promptCalls).toEqual([sessionID])
})
test("triggers retry prompt for nested model error payloads", async () => {
//#given
const sessionID = "ses_main_fallback_nested"
setMainSession(sessionID)
const { handler, abortCalls, promptCalls } = createHandler()
//#when
await handler({
event: {
type: "session.error",
properties: {
sessionID,
error: {
name: "UnknownError",
data: {
error: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
},
},
},
})
//#then
expect(abortCalls).toEqual([sessionID])
expect(promptCalls).toEqual([sessionID])
})
test("triggers retry prompt on session.status retry events and applies fallback", async () => {
//#given
const sessionID = "ses_status_retry_fallback"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
const chatMessageHandler = createChatMessageHandler({
ctx: {
client: {
tui: {
showToast: async () => ({}),
},
},
} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
shouldOverride: () => false,
markApplied: () => {},
},
hooks: {
modelFallback,
stopContinuationGuard: null,
keywordDetector: null,
claudeCodeHooks: null,
autoSlashCommand: null,
startWork: null,
ralphLoop: null,
} as any,
})
await handler({
event: {
type: "message.updated",
properties: {
info: {
id: "msg_user_status_1",
sessionID,
role: "user",
time: { created: 1 },
content: [],
modelID: "claude-opus-4-6-thinking",
providerID: "quotio",
agent: "Sisyphus (Ultraworker)",
path: { cwd: "/tmp", root: "/tmp" },
},
},
},
})
//#when
await handler({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
attempt: 1,
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
next: 1234,
},
},
},
})
const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
await chatMessageHandler(
{
sessionID,
agent: "sisyphus",
model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" },
},
output,
)
//#then
expect(abortCalls).toEqual([sessionID])
expect(promptCalls).toEqual([sessionID])
expect(output.message["model"]).toEqual({
providerID: "quotio",
modelID: "claude-opus-4-6",
})
expect(output.message["variant"]).toBe("max")
})
test("advances main-session fallback chain across repeated session.error retries end-to-end", async () => {
//#given
const abortCalls: string[] = []
const promptCalls: string[] = []
const toastCalls: string[] = []
const sessionID = "ses_main_fallback_chain"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
const eventHandler = createEventHandler({
ctx: {
directory: "/tmp",
client: {
session: {
abort: async ({ path }: { path: { id: string } }) => {
abortCalls.push(path.id)
return {}
},
prompt: async ({ path }: { path: { id: string } }) => {
promptCalls.push(path.id)
return {}
},
},
},
} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
skillMcpManager: {
disconnectSession: async () => {},
},
} as any,
hooks: {
modelFallback,
} as any,
})
const chatMessageHandler = createChatMessageHandler({
ctx: {
client: {
tui: {
showToast: async ({ body }: { body: { title?: string } }) => {
if (body?.title) toastCalls.push(body.title)
return {}
},
},
},
} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
shouldOverride: () => false,
markApplied: () => {},
},
hooks: {
modelFallback,
stopContinuationGuard: null,
keywordDetector: null,
claudeCodeHooks: null,
autoSlashCommand: null,
startWork: null,
ralphLoop: null,
} as any,
})
const triggerRetryCycle = async () => {
await eventHandler({
event: {
type: "session.error",
properties: {
sessionID,
providerID: "quotio",
modelID: "claude-opus-4-6-thinking",
error: {
name: "UnknownError",
data: {
error: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
},
},
},
})
const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
await chatMessageHandler(
{
sessionID,
agent: "sisyphus",
model: { providerID: "quotio", modelID: "claude-opus-4-6-thinking" },
},
output,
)
return output
}
//#when - first retry cycle
const first = await triggerRetryCycle()
//#then - first fallback entry applied (prefers current provider when available)
expect(first.message["model"]).toEqual({
providerID: "quotio",
modelID: "claude-opus-4-6",
})
expect(first.message["variant"]).toBe("max")
//#when - second retry cycle
const second = await triggerRetryCycle()
//#then - second fallback entry applied (chain advanced)
expect(second.message["model"]).toEqual({
providerID: "quotio",
modelID: "gpt-5.3-codex",
})
expect(second.message["variant"]).toBe("high")
expect(abortCalls).toEqual([sessionID, sessionID])
expect(promptCalls).toEqual([sessionID, sessionID])
expect(toastCalls.length).toBeGreaterThanOrEqual(0)
})
})

View File

@@ -4,17 +4,11 @@ import type { PluginContext } from "./types"
import {
clearSessionAgent,
getMainSessionID,
getSessionAgent,
subagentSessions,
syncSubagentSessions,
setMainSession,
updateSessionAgent,
} from "../features/claude-code-session-state"
import { resetMessageCursor } from "../shared"
import { lspManager } from "../tools"
import { shouldRetryError } from "../shared/model-error-classifier"
import { clearPendingModelFallback, clearSessionFallbackChain, setPendingModelFallback } from "../hooks/model-fallback/hook"
import { clearSessionModel, setSessionModel } from "../shared/session-model-state"
import type { CreatedHooks } from "../create-hooks"
import type { Managers } from "../create-managers"
@@ -26,73 +20,9 @@ type FirstMessageVariantGate = {
clear: (sessionID: string) => void
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function normalizeFallbackModelID(modelID: string): string {
return modelID
.replace(/-thinking$/i, "")
.replace(/-max$/i, "")
.replace(/-high$/i, "")
}
function extractErrorName(error: unknown): string | undefined {
if (isRecord(error) && typeof error.name === "string") return error.name
if (error instanceof Error) return error.name
return undefined
}
function extractErrorMessage(error: unknown): string {
if (!error) return ""
if (typeof error === "string") return error
if (error instanceof Error) return error.message
if (isRecord(error)) {
const candidates: unknown[] = [
error,
error.data,
error.error,
isRecord(error.data) ? error.data.error : undefined,
error.cause,
]
for (const candidate of candidates) {
if (isRecord(candidate) && typeof candidate.message === "string" && candidate.message.length > 0) {
return candidate.message
}
}
}
try {
return JSON.stringify(error)
} catch {
return String(error)
}
}
function extractProviderModelFromErrorMessage(
message: string,
): { providerID?: string; modelID?: string } {
const lower = message.toLowerCase()
const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i)
if (providerModel) {
return {
providerID: providerModel[1],
modelID: providerModel[2],
}
}
const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i)
if (modelOnly) {
return {
modelID: modelOnly[1],
}
}
return {}
}
type EventInput = Parameters<
NonNullable<NonNullable<CreatedHooks["writeExistingFileGuard"]>["event"]>
>[0]
export function createEventHandler(args: {
ctx: PluginContext
@@ -100,15 +30,10 @@ export function createEventHandler(args: {
firstMessageVariantGate: FirstMessageVariantGate
managers: Managers
hooks: CreatedHooks
}): (input: { event: { type: string; properties?: Record<string, unknown> } }) => Promise<void> {
}): (input: EventInput) => Promise<void> {
const { ctx, firstMessageVariantGate, managers, hooks } = args
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
const lastHandledModelErrorMessageID = new Map<string, string>()
const lastHandledRetryStatusKey = new Map<string, string>()
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>()
const dispatchToHooks = async (input: { event: { type: string; properties?: Record<string, unknown> } }): Promise<void> => {
const dispatchToHooks = async (input: EventInput): Promise<void> => {
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input))
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input))
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input))
@@ -121,9 +46,10 @@ export function createEventHandler(args: {
await Promise.resolve(hooks.rulesInjector?.event?.(input))
await Promise.resolve(hooks.thinkMode?.event?.(input))
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input))
await Promise.resolve(hooks.runtimeFallback?.event?.(input))
await Promise.resolve(hooks.agentUsageReminder?.event?.(input))
await Promise.resolve(hooks.categorySkillReminder?.event?.(input))
await Promise.resolve(hooks.interactiveBashSession?.event?.(input))
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput))
await Promise.resolve(hooks.ralphLoop?.event?.(input))
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input))
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input))
@@ -135,15 +61,6 @@ export function createEventHandler(args: {
const recentRealIdles = new Map<string, number>()
const DEDUP_WINDOW_MS = 500
const shouldAutoRetrySession = (sessionID: string): boolean => {
if (syncSubagentSessions.has(sessionID)) return true
const mainSessionID = getMainSessionID()
if (mainSessionID) return sessionID === mainSessionID
// Headless runs (or resumed sessions) may not emit session.created, so mainSessionID can be unset.
// In that case, treat any non-subagent session as the "main" interactive session.
return !subagentSessions.has(sessionID)
}
return async (input): Promise<void> => {
pruneRecentSyntheticIdles({
recentSyntheticIdles,
@@ -175,7 +92,7 @@ export function createEventHandler(args: {
return
}
recentSyntheticIdles.set(sessionID, Date.now())
await dispatchToHooks(syntheticIdle)
await dispatchToHooks(syntheticIdle as EventInput)
}
const { event } = input
@@ -210,15 +127,8 @@ export function createEventHandler(args: {
if (sessionInfo?.id) {
clearSessionAgent(sessionInfo.id)
lastHandledModelErrorMessageID.delete(sessionInfo.id)
lastHandledRetryStatusKey.delete(sessionInfo.id)
lastKnownModelBySession.delete(sessionInfo.id)
clearPendingModelFallback(sessionInfo.id)
clearSessionFallbackChain(sessionInfo.id)
resetMessageCursor(sessionInfo.id)
firstMessageVariantGate.clear(sessionInfo.id)
clearSessionModel(sessionInfo.id)
syncSubagentSessions.delete(sessionInfo.id)
await managers.skillMcpManager.disconnectSession(sessionInfo.id)
await lspManager.cleanupTempDirectoryClients()
await managers.tmuxSessionManager.onSessionDeleted({
@@ -232,129 +142,8 @@ export function createEventHandler(args: {
const sessionID = info?.sessionID as string | undefined
const agent = info?.agent as string | undefined
const role = info?.role as string | undefined
if (sessionID && role === "user") {
if (agent) {
updateSessionAgent(sessionID, agent)
}
const providerID = info?.providerID as string | undefined
const modelID = info?.modelID as string | undefined
if (providerID && modelID) {
lastKnownModelBySession.set(sessionID, { providerID, modelID })
setSessionModel(sessionID, { providerID, modelID })
}
}
// Model fallback: in practice, API/model failures often surface as assistant message errors.
// session.error events are not guaranteed for all providers, so we also observe message.updated.
if (sessionID && role === "assistant") {
const assistantMessageID = info?.id as string | undefined
const assistantError = info?.error
if (assistantMessageID && assistantError) {
const lastHandled = lastHandledModelErrorMessageID.get(sessionID)
if (lastHandled === assistantMessageID) {
return
}
const errorName = extractErrorName(assistantError)
const errorMessage = extractErrorMessage(assistantError)
const errorInfo = { name: errorName, message: errorMessage }
if (shouldRetryError(errorInfo)) {
// Prefer the agent/model/provider from the assistant message payload.
let agentName = agent ?? getSessionAgent(sessionID)
if (!agentName && sessionID === getMainSessionID()) {
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
agentName = "sisyphus"
} else if (errorMessage.includes("gpt-5")) {
agentName = "hephaestus"
} else {
agentName = "sisyphus"
}
}
if (agentName) {
const currentProvider = (info?.providerID as string | undefined) ?? "opencode"
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6"
const currentModel = normalizeFallbackModelID(rawModel)
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID)
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await ctx.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: ctx.directory },
})
.catch(() => {})
}
}
}
}
}
}
if (event.type === "session.status") {
const sessionID = props?.sessionID as string | undefined
const status = props?.status as
| { type?: string; attempt?: number; message?: string; next?: number }
| undefined
if (sessionID && status?.type === "retry") {
const retryMessage = typeof status.message === "string" ? status.message : ""
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
return
}
lastHandledRetryStatusKey.set(sessionID, retryKey)
const errorInfo = { name: undefined, message: retryMessage }
if (shouldRetryError(errorInfo)) {
let agentName = getSessionAgent(sessionID)
if (!agentName && sessionID === getMainSessionID()) {
if (retryMessage.includes("claude-opus") || retryMessage.includes("opus")) {
agentName = "sisyphus"
} else if (retryMessage.includes("gpt-5")) {
agentName = "hephaestus"
} else {
agentName = "sisyphus"
}
}
if (agentName) {
const parsed = extractProviderModelFromErrorMessage(retryMessage)
const lastKnown = lastKnownModelBySession.get(sessionID)
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode"
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6"
currentModel = normalizeFallbackModelID(currentModel)
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await ctx.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: ctx.directory },
})
.catch(() => {})
}
}
}
if (sessionID && agent && role === "user") {
updateSessionAgent(sessionID, agent)
}
}
@@ -362,11 +151,6 @@ export function createEventHandler(args: {
const sessionID = props?.sessionID as string | undefined
const error = props?.error
const errorName = extractErrorName(error)
const errorMessage = extractErrorMessage(error)
const errorInfo = { name: errorName, message: errorMessage }
// First, try session recovery for internal errors (thinking blocks, tool results, etc.)
if (hooks.sessionRecovery?.isRecoverableError(error)) {
const messageInfo = {
id: props?.messageID as string | undefined,
@@ -390,52 +174,6 @@ export function createEventHandler(args: {
})
.catch(() => {})
}
}
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
else if (sessionID && shouldRetryError(errorInfo)) {
// Get the current agent for this session, or default to "sisyphus" for main sessions
let agentName = getSessionAgent(sessionID)
// For main sessions, if no agent is set, try to infer from the error or default to sisyphus
if (!agentName && sessionID === getMainSessionID()) {
// Try to infer agent from model in error message
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
agentName = "sisyphus"
} else if (errorMessage.includes("gpt-5")) {
agentName = "hephaestus"
} else {
// Default to sisyphus for main session errors
agentName = "sisyphus"
}
}
if (agentName) {
const parsed = extractProviderModelFromErrorMessage(errorMessage)
const currentProvider = props?.providerID as string || parsed.providerID || "opencode"
let currentModel = props?.modelID as string || parsed.modelID || "claude-opus-4-6"
currentModel = normalizeFallbackModelID(currentModel)
// Try to set pending model fallback
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
// Abort the current session and prompt with "continue" to trigger the fallback
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await ctx.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: ctx.directory },
})
.catch(() => {})
}
}
}
}
}

View File

@@ -7,7 +7,6 @@ import {
createSessionRecoveryHook,
createSessionNotification,
createThinkModeHook,
createModelFallbackHook,
createAnthropicContextWindowLimitRecoveryHook,
createAutoUpdateCheckerHook,
createAgentUsageReminderHook,
@@ -25,13 +24,13 @@ import {
createNoHephaestusNonGptHook,
createQuestionLabelTruncatorHook,
createPreemptiveCompactionHook,
createRuntimeFallbackHook,
} from "../../hooks"
import { createAnthropicEffortHook } from "../../hooks/anthropic-effort"
import {
detectExternalNotificationPlugin,
getNotificationConflictWarning,
log,
normalizeSDKResponse,
} from "../../shared"
import { safeCreateHook } from "../../shared/safe-create-hook"
import { sessionExists } from "../../tools"
@@ -42,7 +41,6 @@ export type SessionHooks = {
sessionRecovery: ReturnType<typeof createSessionRecoveryHook> | null
sessionNotification: ReturnType<typeof createSessionNotification> | null
thinkMode: ReturnType<typeof createThinkModeHook> | null
modelFallback: ReturnType<typeof createModelFallbackHook> | null
anthropicContextWindowLimitRecovery: ReturnType<typeof createAnthropicContextWindowLimitRecoveryHook> | null
autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook> | null
agentUsageReminder: ReturnType<typeof createAgentUsageReminderHook> | null
@@ -60,6 +58,7 @@ export type SessionHooks = {
questionLabelTruncator: ReturnType<typeof createQuestionLabelTruncatorHook>
taskResumeInfo: ReturnType<typeof createTaskResumeInfoHook>
anthropicEffort: ReturnType<typeof createAnthropicEffortHook> | null
runtimeFallback: ReturnType<typeof createRuntimeFallbackHook> | null
}
export function createSessionHooks(args: {
@@ -105,73 +104,6 @@ export function createSessionHooks(args: {
? safeHook("think-mode", () => createThinkModeHook())
: null
const enableFallbackTitle = pluginConfig.experimental?.model_fallback_title ?? false
const fallbackTitleMaxEntries = 200
const fallbackTitleState = new Map<string, { baseTitle?: string; lastKey?: string }>()
const updateFallbackTitle = async (input: {
sessionID: string
providerID: string
modelID: string
variant?: string
}) => {
if (!enableFallbackTitle) return
const key = `${input.providerID}/${input.modelID}${input.variant ? `:${input.variant}` : ""}`
const existing = fallbackTitleState.get(input.sessionID) ?? {}
if (existing.lastKey === key) return
if (!existing.baseTitle) {
const sessionResp = await ctx.client.session.get({ path: { id: input.sessionID } }).catch(() => null)
const sessionInfo = sessionResp
? normalizeSDKResponse(sessionResp, null as { title?: string } | null, { preferResponseOnMissingData: true })
: null
const rawTitle = sessionInfo?.title
if (typeof rawTitle === "string" && rawTitle.length > 0) {
existing.baseTitle = rawTitle.replace(/\s*\[fallback:[^\]]+\]$/i, "").trim()
} else {
existing.baseTitle = "Session"
}
}
const variantLabel = input.variant ? ` ${input.variant}` : ""
const newTitle = `${existing.baseTitle} [fallback: ${input.providerID}/${input.modelID}${variantLabel}]`
await ctx.client.session
.update({
path: { id: input.sessionID },
body: { title: newTitle },
query: { directory: ctx.directory },
})
.catch(() => {})
existing.lastKey = key
fallbackTitleState.set(input.sessionID, existing)
if (fallbackTitleState.size > fallbackTitleMaxEntries) {
const oldestKey = fallbackTitleState.keys().next().value
if (oldestKey) fallbackTitleState.delete(oldestKey)
}
}
// Model fallback hook (configurable via disabled_hooks)
// This handles automatic model switching when model errors occur
const modelFallback = isHookEnabled("model-fallback")
? safeHook("model-fallback", () =>
createModelFallbackHook({
toast: async ({ title, message, variant, duration }) => {
await ctx.client.tui
.showToast({
body: {
title,
message,
variant: variant ?? "warning",
duration: duration ?? 5000,
},
})
.catch(() => {})
},
onApplied: enableFallbackTitle ? updateFallbackTitle : undefined,
}))
: null
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
? safeHook("anthropic-context-window-limit-recovery", () =>
createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental }))
@@ -245,13 +177,19 @@ export function createSessionHooks(args: {
? safeHook("anthropic-effort", () => createAnthropicEffortHook())
: null
const runtimeFallback = isHookEnabled("runtime-fallback")
? safeHook("runtime-fallback", () =>
createRuntimeFallbackHook(ctx, {
config: pluginConfig.runtime_fallback,
pluginConfig,
}))
: null
return {
contextWindowMonitor,
preemptiveCompaction,
sessionRecovery,
sessionNotification,
thinkMode,
modelFallback,
anthropicContextWindowLimitRecovery,
autoUpdateChecker,
agentUsageReminder,
@@ -269,5 +207,6 @@ export function createSessionHooks(args: {
questionLabelTruncator,
taskResumeInfo,
anthropicEffort,
runtimeFallback,
}
}

View File

@@ -5,7 +5,6 @@ import {
createClaudeCodeHooksHook,
createKeywordDetectorHook,
createThinkingBlockValidatorHook,
createBeastModeSystemHook,
} from "../../hooks"
import {
contextCollector,
@@ -18,7 +17,6 @@ export type TransformHooks = {
keywordDetector: ReturnType<typeof createKeywordDetectorHook> | null
contextInjectorMessagesTransform: ReturnType<typeof createContextInjectorMessagesTransformHook>
thinkingBlockValidator: ReturnType<typeof createThinkingBlockValidatorHook> | null
beastModeSystem: ReturnType<typeof createBeastModeSystemHook> | null
}
export function createTransformHooks(args: {
@@ -58,19 +56,10 @@ export function createTransformHooks(args: {
)
: null
const beastModeSystem = isHookEnabled("beast-mode-system")
? safeCreateHook(
"beast-mode-system",
() => createBeastModeSystemHook(),
{ enabled: safeHookEnabled },
)
: null
return {
claudeCodeHooks,
keywordDetector,
contextInjectorMessagesTransform,
thinkingBlockValidator,
beastModeSystem,
}
}

View File

@@ -1,12 +0,0 @@
import type { CreatedHooks } from "../create-hooks"
export function createSystemTransformHandler(args: {
hooks: CreatedHooks
}): (input: { sessionID: string }, output: { system: string[] }) => Promise<void> {
return async (input, output): Promise<void> => {
await args.hooks.beastModeSystem?.["experimental.chat.system.transform"]?.(
input,
output,
)
}
}

View File

@@ -75,14 +75,6 @@ function findVariantInChain(
return entry.variant
}
}
// Some providers expose identical model IDs (e.g. OpenAI models via different providers).
// If we didn't find an exact provider+model match, fall back to model-only matching.
for (const entry of fallbackChain) {
if (entry.model === currentModel.modelID) {
return entry.variant
}
}
return undefined
}

View File

@@ -34,6 +34,7 @@ export * from "./system-directive"
export * from "./agent-tool-restrictions"
export * from "./model-requirements"
export * from "./model-resolver"
export { normalizeFallbackModels } from "./model-resolver"
export { resolveModelPipeline } from "./model-resolution-pipeline"
export type {
ModelResolutionRequest,
@@ -58,3 +59,4 @@ export * from "./normalize-sdk-response"
export * from "./session-directory-resolver"
export * from "./prompt-tools"
export * from "./internal-initiator-marker"
export { SessionCategoryRegistry } from "./session-category-registry"

View File

@@ -1,76 +0,0 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"
import { join } from "node:path"
import * as dataPath from "./data-path"
import { shouldRetryError, selectFallbackProvider } from "./model-error-classifier"
const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__")
describe("model-error-classifier", () => {
let cacheDirSpy: ReturnType<typeof spyOn>
beforeEach(() => {
cacheDirSpy = spyOn(dataPath, "getOmoOpenCodeCacheDir").mockReturnValue(TEST_CACHE_DIR)
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true })
}
mkdirSync(TEST_CACHE_DIR, { recursive: true })
})
afterEach(() => {
cacheDirSpy.mockRestore()
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true })
}
})
test("treats overloaded retry messages as retryable", () => {
//#given
const error = { message: "Provider is overloaded" }
//#when
const result = shouldRetryError(error)
//#then
expect(result).toBe(true)
})
test("selectFallbackProvider prefers first connected provider in preference order", () => {
//#given
writeFileSync(
join(TEST_CACHE_DIR, "connected-providers.json"),
JSON.stringify({ connected: ["quotio", "nvidia"], updatedAt: new Date().toISOString() }, null, 2),
)
//#when
const provider = selectFallbackProvider(["quotio", "nvidia"], "nvidia")
//#then
expect(provider).toBe("quotio")
})
test("selectFallbackProvider falls back to next connected provider when first is disconnected", () => {
//#given
writeFileSync(
join(TEST_CACHE_DIR, "connected-providers.json"),
JSON.stringify({ connected: ["nvidia"], updatedAt: new Date().toISOString() }, null, 2),
)
//#when
const provider = selectFallbackProvider(["quotio", "nvidia"])
//#then
expect(provider).toBe("nvidia")
})
test("selectFallbackProvider uses provider preference order when cache is missing", () => {
//#given - no cache file
//#when
const provider = selectFallbackProvider(["quotio", "nvidia"], "nvidia")
//#then
expect(provider).toBe("quotio")
})
})

View File

@@ -1,135 +0,0 @@
import type { FallbackEntry } from "./model-requirements"
import { readConnectedProvidersCache } from "./connected-providers-cache"
/**
* Error names that indicate a retryable model error (deadstop).
* These errors completely halt the action loop and should trigger fallback retry.
*/
const RETRYABLE_ERROR_NAMES = new Set([
"ProviderModelNotFoundError",
"RateLimitError",
"QuotaExceededError",
"InsufficientCreditsError",
"ModelUnavailableError",
"ProviderConnectionError",
"AuthenticationError",
])
/**
* Error names that should NOT trigger retry.
* These errors are typically user-induced or fixable without switching models.
*/
const NON_RETRYABLE_ERROR_NAMES = new Set([
"MessageAbortedError",
"PermissionDeniedError",
"ContextLengthError",
"TimeoutError",
"ValidationError",
"SyntaxError",
"UserError",
])
/**
* Message patterns that indicate a retryable error even without a known error name.
*/
const RETRYABLE_MESSAGE_PATTERNS = [
"rate_limit",
"rate limit",
"quota",
"not found",
"unavailable",
"insufficient",
"too many requests",
"over limit",
"overloaded",
"bad gateway",
"unknown provider",
"provider not found",
"connection error",
"network error",
"timeout",
"service unavailable",
"internal_server_error",
"503",
"502",
"504",
]
export interface ErrorInfo {
name?: string
message?: string
}
/**
* Determines if an error is a retryable model error.
* Returns true if the error is a known retryable type OR matches retryable message patterns.
*/
export function isRetryableModelError(error: ErrorInfo): boolean {
// If we have an error name, check against known lists
if (error.name) {
// Explicit non-retryable takes precedence
if (NON_RETRYABLE_ERROR_NAMES.has(error.name)) {
return false
}
// Check if it's a known retryable error
if (RETRYABLE_ERROR_NAMES.has(error.name)) {
return true
}
}
// Check message patterns for unknown errors
const msg = error.message?.toLowerCase() ?? ""
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))
}
/**
* Determines if an error should trigger a fallback retry.
* Returns true for deadstop errors that completely halt the action loop.
*/
export function shouldRetryError(error: ErrorInfo): boolean {
return isRetryableModelError(error)
}
/**
* Gets the next fallback model from the chain based on attempt count.
* Returns undefined if all fallbacks have been exhausted.
*/
export function getNextFallback(
fallbackChain: FallbackEntry[],
attemptCount: number,
): FallbackEntry | undefined {
return fallbackChain[attemptCount]
}
/**
* Checks if there are more fallbacks available after the current attempt.
*/
export function hasMoreFallbacks(
fallbackChain: FallbackEntry[],
attemptCount: number,
): boolean {
return attemptCount < fallbackChain.length
}
/**
* Selects the best provider for a fallback entry.
* Priority:
* 1) First connected provider in the entry's provider preference order
* 2) First provider listed in the fallback entry (when cache is missing)
*/
export function selectFallbackProvider(
providers: string[],
preferredProviderID?: string,
): string {
const connectedProviders = readConnectedProvidersCache()
if (connectedProviders) {
const connectedSet = new Set(connectedProviders)
for (const provider of providers) {
if (connectedSet.has(provider)) {
return provider
}
}
}
return providers[0] || preferredProviderID || "quotio"
}

View File

@@ -6,158 +6,493 @@ import {
type ModelRequirement,
} from "./model-requirements"
function flattenChains(): FallbackEntry[] {
return [
...Object.values(AGENT_MODEL_REQUIREMENTS).flatMap((r) => r.fallbackChain),
...Object.values(CATEGORY_MODEL_REQUIREMENTS).flatMap((r) => r.fallbackChain),
]
}
function assertNoExcludedModels(entry: FallbackEntry): void {
// User exclusions.
expect(entry.model).not.toBe("grok-code-fast-1")
if (entry.providers.includes("quotio")) {
expect(entry.model).not.toBe("tstars2.0")
expect(entry.model).not.toMatch(/^kiro-/i)
expect(entry.model).not.toMatch(/^tab_/i)
}
// Remove codex-mini models per request.
expect(entry.model).not.toMatch(/codex-mini/i)
}
function assertNoOpencodeProvider(entry: FallbackEntry): void {
expect(entry.providers).not.toContain("opencode")
}
function assertNoProviderPrefixForNonNamespacedProviders(entry: FallbackEntry): void {
// For these providers, model IDs should not be written as "provider/model".
const nonNamespaced = ["quotio", "openai", "github-copilot", "minimax", "minimax-coding-plan"]
for (const provider of entry.providers) {
if (!nonNamespaced.includes(provider)) continue
expect(entry.model.startsWith(`${provider}/`)).toBe(false)
}
}
describe("AGENT_MODEL_REQUIREMENTS", () => {
test("defines all 10 builtin agents", () => {
expect(Object.keys(AGENT_MODEL_REQUIREMENTS).sort()).toEqual([
"atlas",
"explore",
test("oracle has valid fallbackChain with gpt-5.2 as primary", () => {
// given - oracle agent requirement
const oracle = AGENT_MODEL_REQUIREMENTS["oracle"]
// when - accessing oracle requirement
// then - fallbackChain exists with gpt-5.2 as first entry
expect(oracle).toBeDefined()
expect(oracle.fallbackChain).toBeArray()
expect(oracle.fallbackChain.length).toBeGreaterThan(0)
const primary = oracle.fallbackChain[0]
expect(primary.providers).toContain("openai")
expect(primary.model).toBe("gpt-5.2")
expect(primary.variant).toBe("high")
})
test("sisyphus has claude-opus-4-6 as primary and requiresAnyModel", () => {
// #given - sisyphus agent requirement
const sisyphus = AGENT_MODEL_REQUIREMENTS["sisyphus"]
// #when - accessing Sisyphus requirement
// #then - fallbackChain has claude-opus-4-6 first, big-pickle last
expect(sisyphus).toBeDefined()
expect(sisyphus.fallbackChain).toBeArray()
expect(sisyphus.fallbackChain).toHaveLength(5)
expect(sisyphus.requiresAnyModel).toBe(true)
const primary = sisyphus.fallbackChain[0]
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
expect(primary.model).toBe("claude-opus-4-6")
expect(primary.variant).toBe("max")
const last = sisyphus.fallbackChain[4]
expect(last.providers[0]).toBe("opencode")
expect(last.model).toBe("big-pickle")
})
test("librarian has valid fallbackChain with gemini-3-flash as primary", () => {
// given - librarian agent requirement
const librarian = AGENT_MODEL_REQUIREMENTS["librarian"]
// when - accessing librarian requirement
// then - fallbackChain exists with gemini-3-flash as first entry
expect(librarian).toBeDefined()
expect(librarian.fallbackChain).toBeArray()
expect(librarian.fallbackChain.length).toBeGreaterThan(0)
const primary = librarian.fallbackChain[0]
expect(primary.providers[0]).toBe("google")
expect(primary.model).toBe("gemini-3-flash")
})
test("explore has valid fallbackChain with grok-code-fast-1 as primary", () => {
// given - explore agent requirement
const explore = AGENT_MODEL_REQUIREMENTS["explore"]
// when - accessing explore requirement
// then - fallbackChain: grok → minimax-free → haiku → nano
expect(explore).toBeDefined()
expect(explore.fallbackChain).toBeArray()
expect(explore.fallbackChain).toHaveLength(4)
const primary = explore.fallbackChain[0]
expect(primary.providers).toContain("github-copilot")
expect(primary.model).toBe("grok-code-fast-1")
const secondary = explore.fallbackChain[1]
expect(secondary.providers).toContain("opencode")
expect(secondary.model).toBe("minimax-m2.5-free")
const tertiary = explore.fallbackChain[2]
expect(tertiary.providers).toContain("anthropic")
expect(tertiary.model).toBe("claude-haiku-4-5")
const quaternary = explore.fallbackChain[3]
expect(quaternary.providers).toContain("opencode")
expect(quaternary.model).toBe("gpt-5-nano")
})
test("multimodal-looker has valid fallbackChain with k2p5 as primary", () => {
// given - multimodal-looker agent requirement
const multimodalLooker = AGENT_MODEL_REQUIREMENTS["multimodal-looker"]
// when - accessing multimodal-looker requirement
// then - fallbackChain exists with k2p5 as first entry
expect(multimodalLooker).toBeDefined()
expect(multimodalLooker.fallbackChain).toBeArray()
expect(multimodalLooker.fallbackChain.length).toBeGreaterThan(0)
const primary = multimodalLooker.fallbackChain[0]
expect(primary.providers[0]).toBe("kimi-for-coding")
expect(primary.model).toBe("k2p5")
})
test("prometheus has claude-opus-4-6 as primary", () => {
// #given - prometheus agent requirement
const prometheus = AGENT_MODEL_REQUIREMENTS["prometheus"]
// #when - accessing Prometheus requirement
// #then - claude-opus-4-6 is first
expect(prometheus).toBeDefined()
expect(prometheus.fallbackChain).toBeArray()
expect(prometheus.fallbackChain.length).toBeGreaterThan(1)
const primary = prometheus.fallbackChain[0]
expect(primary.model).toBe("claude-opus-4-6")
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
expect(primary.variant).toBe("max")
})
test("metis has claude-opus-4-6 as primary", () => {
// #given - metis agent requirement
const metis = AGENT_MODEL_REQUIREMENTS["metis"]
// #when - accessing Metis requirement
// #then - claude-opus-4-6 is first
expect(metis).toBeDefined()
expect(metis.fallbackChain).toBeArray()
expect(metis.fallbackChain.length).toBeGreaterThan(1)
const primary = metis.fallbackChain[0]
expect(primary.model).toBe("claude-opus-4-6")
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
expect(primary.variant).toBe("max")
})
test("momus has valid fallbackChain with gpt-5.2 as primary", () => {
// given - momus agent requirement
const momus = AGENT_MODEL_REQUIREMENTS["momus"]
// when - accessing Momus requirement
// then - fallbackChain exists with gpt-5.2 as first entry, variant medium
expect(momus).toBeDefined()
expect(momus.fallbackChain).toBeArray()
expect(momus.fallbackChain.length).toBeGreaterThan(0)
const primary = momus.fallbackChain[0]
expect(primary.model).toBe("gpt-5.2")
expect(primary.variant).toBe("medium")
expect(primary.providers[0]).toBe("openai")
})
test("atlas has valid fallbackChain with k2p5 as primary (kimi-for-coding prioritized)", () => {
// given - atlas agent requirement
const atlas = AGENT_MODEL_REQUIREMENTS["atlas"]
// when - accessing Atlas requirement
// then - fallbackChain exists with k2p5 as first entry (kimi-for-coding prioritized)
expect(atlas).toBeDefined()
expect(atlas.fallbackChain).toBeArray()
expect(atlas.fallbackChain.length).toBeGreaterThan(0)
const primary = atlas.fallbackChain[0]
expect(primary.model).toBe("k2p5")
expect(primary.providers[0]).toBe("kimi-for-coding")
})
test("hephaestus requires openai/github-copilot/opencode provider", () => {
// #given - hephaestus agent requirement
const hephaestus = AGENT_MODEL_REQUIREMENTS["hephaestus"]
// #when - accessing hephaestus requirement
// #then - requiresProvider is set to openai, github-copilot, opencode (not requiresModel)
expect(hephaestus).toBeDefined()
expect(hephaestus.requiresProvider).toEqual(["openai", "github-copilot", "opencode"])
expect(hephaestus.requiresModel).toBeUndefined()
})
test("all 10 builtin agents have valid fallbackChain arrays", () => {
// #given - list of 10 agent names
const expectedAgents = [
"sisyphus",
"hephaestus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"prometheus",
"metis",
"momus",
"multimodal-looker",
"oracle",
"prometheus",
"sisyphus",
])
})
"atlas",
]
test("sisyphus: 2nd fallback is quotio gpt-5.3-codex (high)", () => {
const sisyphus = AGENT_MODEL_REQUIREMENTS["sisyphus"]
expect(sisyphus.requiresAnyModel).toBe(true)
expect(sisyphus.fallbackChain.length).toBeGreaterThan(2)
// when - checking AGENT_MODEL_REQUIREMENTS
const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS)
expect(sisyphus.fallbackChain[0]).toEqual({
providers: ["quotio"],
model: "claude-opus-4-6",
variant: "max",
})
// #then - all agents present with valid fallbackChain
expect(definedAgents).toHaveLength(10)
for (const agent of expectedAgents) {
const requirement = AGENT_MODEL_REQUIREMENTS[agent]
expect(requirement).toBeDefined()
expect(requirement.fallbackChain).toBeArray()
expect(requirement.fallbackChain.length).toBeGreaterThan(0)
expect(sisyphus.fallbackChain[1]).toEqual({
providers: ["quotio"],
model: "gpt-5.3-codex",
variant: "high",
})
})
test("explore: uses speed chain, includes rome, and gpt-5-mini is copilot-first", () => {
const explore = AGENT_MODEL_REQUIREMENTS["explore"]
expect(explore.fallbackChain.length).toBeGreaterThan(4)
expect(explore.fallbackChain[0].model).toBe("claude-haiku-4-5")
expect(explore.fallbackChain.some((e) => e.model === "iflow-rome-30ba3b")).toBe(true)
const gptMini = explore.fallbackChain.find((e) => e.model === "gpt-5-mini")
expect(gptMini).toBeDefined()
expect(gptMini!.providers[0]).toBe("github-copilot")
expect(gptMini!.variant).toBe("high")
})
test("multimodal-looker: prefers gemini image model first", () => {
const multimodal = AGENT_MODEL_REQUIREMENTS["multimodal-looker"]
expect(multimodal.fallbackChain[0]).toEqual({
providers: ["quotio"],
model: "gemini-3-pro-image",
})
})
test("includes NVIDIA NIM additions in at least one agent chain", () => {
const all = Object.values(AGENT_MODEL_REQUIREMENTS).flatMap((r) => r.fallbackChain)
expect(all.some((e) => e.providers.includes("nvidia") && e.model === "qwen/qwen3.5-397b-a17b")).toBe(true)
expect(all.some((e) => e.providers.includes("nvidia") && e.model === "stepfun-ai/step-3.5-flash")).toBe(true)
expect(all.some((e) => e.providers.includes("nvidia") && e.model === "bytedance/seed-oss-36b-instruct")).toBe(true)
for (const entry of requirement.fallbackChain) {
expect(entry.providers).toBeArray()
expect(entry.providers.length).toBeGreaterThan(0)
expect(typeof entry.model).toBe("string")
expect(entry.model.length).toBeGreaterThan(0)
}
}
})
})
describe("CATEGORY_MODEL_REQUIREMENTS", () => {
test("defines all 8 categories", () => {
expect(Object.keys(CATEGORY_MODEL_REQUIREMENTS).sort()).toEqual([
"artistry",
"deep",
"quick",
"ultrabrain",
"unspecified-high",
"unspecified-low",
"visual-engineering",
"writing",
])
})
test("deep requires gpt-5.3-codex", () => {
expect(CATEGORY_MODEL_REQUIREMENTS["deep"].requiresModel).toBe("gpt-5.3-codex")
})
test("quick uses the speed chain (haiku primary)", () => {
expect(CATEGORY_MODEL_REQUIREMENTS["quick"].fallbackChain[0].model).toBe("claude-haiku-4-5")
})
test("ultrabrain starts with gpt-5.3-codex (high)", () => {
test("ultrabrain has valid fallbackChain with gpt-5.3-codex as primary", () => {
// given - ultrabrain category requirement
const ultrabrain = CATEGORY_MODEL_REQUIREMENTS["ultrabrain"]
expect(ultrabrain.fallbackChain[0]).toEqual({
providers: ["quotio"],
model: "gpt-5.3-codex",
// when - accessing ultrabrain requirement
// then - fallbackChain exists with gpt-5.3-codex as first entry
expect(ultrabrain).toBeDefined()
expect(ultrabrain.fallbackChain).toBeArray()
expect(ultrabrain.fallbackChain.length).toBeGreaterThan(0)
const primary = ultrabrain.fallbackChain[0]
expect(primary.variant).toBe("xhigh")
expect(primary.model).toBe("gpt-5.3-codex")
expect(primary.providers[0]).toBe("openai")
})
test("deep has valid fallbackChain with gpt-5.3-codex as primary", () => {
// given - deep category requirement
const deep = CATEGORY_MODEL_REQUIREMENTS["deep"]
// when - accessing deep requirement
// then - fallbackChain exists with gpt-5.3-codex as first entry, medium variant
expect(deep).toBeDefined()
expect(deep.fallbackChain).toBeArray()
expect(deep.fallbackChain.length).toBeGreaterThan(0)
const primary = deep.fallbackChain[0]
expect(primary.variant).toBe("medium")
expect(primary.model).toBe("gpt-5.3-codex")
expect(primary.providers[0]).toBe("openai")
})
test("visual-engineering has valid fallbackChain with gemini-3-pro high as primary", () => {
// given - visual-engineering category requirement
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
// when - accessing visual-engineering requirement
// then - fallbackChain: gemini-3-pro(high) → glm-5 → opus-4-6(max) → k2p5
expect(visualEngineering).toBeDefined()
expect(visualEngineering.fallbackChain).toBeArray()
expect(visualEngineering.fallbackChain).toHaveLength(4)
const primary = visualEngineering.fallbackChain[0]
expect(primary.providers[0]).toBe("google")
expect(primary.model).toBe("gemini-3-pro")
expect(primary.variant).toBe("high")
const second = visualEngineering.fallbackChain[1]
expect(second.providers[0]).toBe("zai-coding-plan")
expect(second.model).toBe("glm-5")
const third = visualEngineering.fallbackChain[2]
expect(third.model).toBe("claude-opus-4-6")
expect(third.variant).toBe("max")
const fourth = visualEngineering.fallbackChain[3]
expect(fourth.providers[0]).toBe("kimi-for-coding")
expect(fourth.model).toBe("k2p5")
})
test("quick has valid fallbackChain with claude-haiku-4-5 as primary", () => {
// given - quick category requirement
const quick = CATEGORY_MODEL_REQUIREMENTS["quick"]
// when - accessing quick requirement
// then - fallbackChain exists with claude-haiku-4-5 as first entry
expect(quick).toBeDefined()
expect(quick.fallbackChain).toBeArray()
expect(quick.fallbackChain.length).toBeGreaterThan(0)
const primary = quick.fallbackChain[0]
expect(primary.model).toBe("claude-haiku-4-5")
expect(primary.providers[0]).toBe("anthropic")
})
test("unspecified-low has valid fallbackChain with claude-sonnet-4-6 as primary", () => {
// given - unspecified-low category requirement
const unspecifiedLow = CATEGORY_MODEL_REQUIREMENTS["unspecified-low"]
// when - accessing unspecified-low requirement
// then - fallbackChain exists with claude-sonnet-4-6 as first entry
expect(unspecifiedLow).toBeDefined()
expect(unspecifiedLow.fallbackChain).toBeArray()
expect(unspecifiedLow.fallbackChain.length).toBeGreaterThan(0)
const primary = unspecifiedLow.fallbackChain[0]
expect(primary.model).toBe("claude-sonnet-4-6")
expect(primary.providers[0]).toBe("anthropic")
})
test("unspecified-high has claude-opus-4-6 as primary", () => {
// #given - unspecified-high category requirement
const unspecifiedHigh = CATEGORY_MODEL_REQUIREMENTS["unspecified-high"]
// #when - accessing unspecified-high requirement
// #then - claude-opus-4-6 is first
expect(unspecifiedHigh).toBeDefined()
expect(unspecifiedHigh.fallbackChain).toBeArray()
expect(unspecifiedHigh.fallbackChain.length).toBeGreaterThan(1)
const primary = unspecifiedHigh.fallbackChain[0]
expect(primary.model).toBe("claude-opus-4-6")
expect(primary.variant).toBe("max")
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
})
test("artistry has valid fallbackChain with gemini-3-pro as primary", () => {
// given - artistry category requirement
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
// when - accessing artistry requirement
// then - fallbackChain exists with gemini-3-pro as first entry
expect(artistry).toBeDefined()
expect(artistry.fallbackChain).toBeArray()
expect(artistry.fallbackChain.length).toBeGreaterThan(0)
const primary = artistry.fallbackChain[0]
expect(primary.model).toBe("gemini-3-pro")
expect(primary.variant).toBe("high")
expect(primary.providers[0]).toBe("google")
})
test("writing has valid fallbackChain with k2p5 as primary (kimi-for-coding)", () => {
// given - writing category requirement
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
// when - accessing writing requirement
// then - fallbackChain: k2p5 → gemini-3-flash → claude-sonnet-4-6
expect(writing).toBeDefined()
expect(writing.fallbackChain).toBeArray()
expect(writing.fallbackChain).toHaveLength(3)
const primary = writing.fallbackChain[0]
expect(primary.model).toBe("k2p5")
expect(primary.providers[0]).toBe("kimi-for-coding")
const second = writing.fallbackChain[1]
expect(second.model).toBe("gemini-3-flash")
expect(second.providers[0]).toBe("google")
})
test("all 8 categories have valid fallbackChain arrays", () => {
// given - list of 8 category names
const expectedCategories = [
"visual-engineering",
"ultrabrain",
"deep",
"artistry",
"quick",
"unspecified-low",
"unspecified-high",
"writing",
]
// when - checking CATEGORY_MODEL_REQUIREMENTS
const definedCategories = Object.keys(CATEGORY_MODEL_REQUIREMENTS)
// then - all categories present with valid fallbackChain
expect(definedCategories).toHaveLength(8)
for (const category of expectedCategories) {
const requirement = CATEGORY_MODEL_REQUIREMENTS[category]
expect(requirement).toBeDefined()
expect(requirement.fallbackChain).toBeArray()
expect(requirement.fallbackChain.length).toBeGreaterThan(0)
for (const entry of requirement.fallbackChain) {
expect(entry.providers).toBeArray()
expect(entry.providers.length).toBeGreaterThan(0)
expect(typeof entry.model).toBe("string")
expect(entry.model.length).toBeGreaterThan(0)
}
}
})
})
describe("FallbackEntry type", () => {
test("FallbackEntry structure is correct", () => {
// given - a valid FallbackEntry object
const entry: FallbackEntry = {
providers: ["anthropic", "github-copilot", "opencode"],
model: "claude-opus-4-6",
variant: "high",
})
})
})
describe("ModelRequirements invariants", () => {
test("all entries have non-empty providers and a non-empty model", () => {
for (const entry of flattenChains()) {
expect(entry.providers.length).toBeGreaterThan(0)
expect(typeof entry.model).toBe("string")
expect(entry.model.length).toBeGreaterThan(0)
}
// when - accessing properties
// then - all properties are accessible
expect(entry.providers).toEqual(["anthropic", "github-copilot", "opencode"])
expect(entry.model).toBe("claude-opus-4-6")
expect(entry.variant).toBe("high")
})
test("no entry uses opencode provider and no excluded models are present", () => {
for (const entry of flattenChains()) {
assertNoOpencodeProvider(entry)
assertNoExcludedModels(entry)
assertNoProviderPrefixForNonNamespacedProviders(entry)
test("FallbackEntry variant is optional", () => {
// given - a FallbackEntry without variant
const entry: FallbackEntry = {
providers: ["opencode", "anthropic"],
model: "big-pickle",
}
})
})
describe("Type sanity", () => {
test("FallbackEntry.variant is optional", () => {
const entry: FallbackEntry = { providers: ["quotio"], model: "claude-haiku-4-5" }
// when - accessing variant
// then - variant is undefined
expect(entry.variant).toBeUndefined()
})
})
test("ModelRequirement.variant is optional", () => {
const req: ModelRequirement = { fallbackChain: [{ providers: ["quotio"], model: "claude-haiku-4-5" }] }
expect(req.variant).toBeUndefined()
describe("ModelRequirement type", () => {
test("ModelRequirement structure with fallbackChain is correct", () => {
// given - a valid ModelRequirement object
const requirement: ModelRequirement = {
fallbackChain: [
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot"], model: "gpt-5.2", variant: "high" },
],
}
// when - accessing properties
// then - fallbackChain is accessible with correct structure
expect(requirement.fallbackChain).toBeArray()
expect(requirement.fallbackChain).toHaveLength(2)
expect(requirement.fallbackChain[0].model).toBe("claude-opus-4-6")
expect(requirement.fallbackChain[1].model).toBe("gpt-5.2")
})
test("ModelRequirement variant is optional", () => {
// given - a ModelRequirement without top-level variant
const requirement: ModelRequirement = {
fallbackChain: [{ providers: ["opencode"], model: "big-pickle" }],
}
// when - accessing variant
// then - variant is undefined
expect(requirement.variant).toBeUndefined()
})
test("no model in fallbackChain has provider prefix", () => {
// given - all agent and category requirements
const allRequirements = [
...Object.values(AGENT_MODEL_REQUIREMENTS),
...Object.values(CATEGORY_MODEL_REQUIREMENTS),
]
// when - checking each model in fallbackChain
// then - none contain "/" (provider prefix)
for (const req of allRequirements) {
for (const entry of req.fallbackChain) {
expect(entry.model).not.toContain("/")
}
}
})
test("all fallbackChain entries have non-empty providers array", () => {
// given - all agent and category requirements
const allRequirements = [
...Object.values(AGENT_MODEL_REQUIREMENTS),
...Object.values(CATEGORY_MODEL_REQUIREMENTS),
]
// when - checking each entry in fallbackChain
// then - all have non-empty providers array
for (const req of allRequirements) {
for (const entry of req.fallbackChain) {
expect(entry.providers).toBeArray()
expect(entry.providers.length).toBeGreaterThan(0)
}
}
})
})
describe("requiresModel field in categories", () => {
test("deep category has requiresModel set to gpt-5.3-codex", () => {
// given
const deep = CATEGORY_MODEL_REQUIREMENTS["deep"]
// when / #then
expect(deep.requiresModel).toBe("gpt-5.3-codex")
})
test("artistry category has requiresModel set to gemini-3-pro", () => {
// given
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
// when / #then
expect(artistry.requiresModel).toBe("gemini-3-pro")
})
})

View File

@@ -12,203 +12,147 @@ export type ModelRequirement = {
requiresProvider?: string[] // If set, only activates when any of these providers is connected
}
function fb(providers: string[] | string, model: string, variant?: string): FallbackEntry {
return {
providers: Array.isArray(providers) ? providers : [providers],
model,
...(variant !== undefined ? { variant } : {}),
}
}
function dedupeChain(chain: FallbackEntry[]): FallbackEntry[] {
const seen = new Set<string>()
const result: FallbackEntry[] = []
for (const entry of chain) {
const key = `${entry.model}:${entry.variant ?? ""}`
if (seen.has(key)) continue
seen.add(key)
result.push(entry)
}
return result
}
// Provider preference rules:
// - Never use the paid `opencode` provider as an automatic fallback.
// - Prefer `quotio` when the same model exists across multiple providers.
// - Prefer `github-copilot` first for `gpt-5-mini` (unlimited), fall back to `quotio`.
// Note: user requested "Quotio-first" and to avoid the OpenCode provider; we keep runtime fallbacks on
// `quotio` + `nvidia` (+ `github-copilot` for unlimited GPT mini) unless explicitly requested otherwise.
const P_GPT: string[] = ["quotio"]
const P_GPT_MINI: string[] = ["github-copilot", "quotio"]
// Benchmark-driven ordering (user-provided table + NVIDIA NIM docs), tuned per-agent for quality vs speed.
const SPEED_CHAIN: FallbackEntry[] = [
fb("quotio", "claude-haiku-4-5"), fb("quotio", "oswe-vscode-prime"),
fb(P_GPT_MINI, "gpt-5-mini", "high"), fb(P_GPT_MINI, "gpt-4.1"),
fb("nvidia", "nvidia/nemotron-3-nano-30b-a3b"), fb("quotio", "iflow-rome-30ba3b"),
fb("minimax-coding-plan", "MiniMax-M2.5"), fb("nvidia", "bytedance/seed-oss-36b-instruct"),
fb("quotio", "claude-sonnet-4-5"),
]
const QUALITY_CODING_CHAIN: FallbackEntry[] = [
fb("quotio", "claude-opus-4-6-thinking"),
fb("nvidia", "stepfun-ai/step-3.5-flash"),
fb("nvidia", "qwen/qwen3.5-397b-a17b"),
fb("quotio", "glm-5"),
fb("nvidia", "z-ai/glm5"),
fb("quotio", "deepseek-v3.2-reasoner"),
fb("quotio", "deepseek-r1"),
fb("nvidia", "deepseek-ai/deepseek-r1"),
fb("quotio", "qwen3-235b-a22b-thinking-2507"),
fb("nvidia", "qwen/qwen3-next-80b-a3b-thinking"),
fb("nvidia", "qwen/qwen3-coder-480b-a35b-instruct"),
fb("nvidia", "bytedance/seed-oss-36b-instruct"),
fb("quotio", "kimi-k2-thinking"),
fb("quotio", "kimi-k2.5"),
fb("nvidia", "moonshotai/kimi-k2.5"),
fb("minimax-coding-plan", "MiniMax-M2.5"),
fb("minimax-coding-plan", "MiniMax-M2.5-highspeed"),
fb("minimax", "MiniMax-M2.5"),
fb("quotio", "minimax-m2.5"),
fb("quotio", "claude-sonnet-4-5-thinking"),
]
export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
sisyphus: {
fallbackChain: [
// 1st fallback: switch away from Opus Thinking to the non-thinking model (often more available).
fb("quotio", "claude-opus-4-6", "max"),
// 2nd fallback: user-requested.
fb("quotio", "gpt-5.3-codex", "high"),
...QUALITY_CODING_CHAIN,
...SPEED_CHAIN,
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["zai-coding-plan", "opencode"], model: "glm-5" },
{ providers: ["opencode"], model: "big-pickle" },
],
requiresAnyModel: true,
},
hephaestus: {
fallbackChain: [
fb("quotio", "gpt-5.3-codex", "high"),
...QUALITY_CODING_CHAIN,
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
],
requiresAnyModel: true,
requiresProvider: ["openai", "github-copilot", "opencode"],
},
oracle: {
fallbackChain: dedupeChain([
fb("quotio", "gpt-5.3-codex", "high"),
fb("quotio", "claude-opus-4-6-thinking"),
fb("quotio", "claude-sonnet-4-5-thinking"),
...QUALITY_CODING_CHAIN,
]),
},
librarian: {
fallbackChain: [
fb("quotio", "claude-sonnet-4-5"),
...SPEED_CHAIN,
...QUALITY_CODING_CHAIN,
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
],
},
librarian: {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["opencode"], model: "minimax-m2.5-free" },
{ providers: ["opencode"], model: "big-pickle" },
],
},
explore: {
fallbackChain: SPEED_CHAIN,
fallbackChain: [
{ providers: ["github-copilot"], model: "grok-code-fast-1" },
{ providers: ["opencode"], model: "minimax-m2.5-free" },
{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["opencode"], model: "gpt-5-nano" },
],
},
"multimodal-looker": {
fallbackChain: [
fb("quotio", "gemini-3-pro-image"),
fb("quotio", "gemini-3-pro-high"),
fb("quotio", "gemini-3-flash"),
fb("quotio", "kimi-k2.5"),
fb("quotio", "claude-opus-4-6-thinking"),
fb("quotio", "claude-sonnet-4-5-thinking"),
fb("quotio", "claude-haiku-4-5"),
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
{ providers: ["zai-coding-plan"], model: "glm-4.6v" },
],
},
prometheus: {
fallbackChain: dedupeChain([
fb("quotio", "claude-opus-4-6-thinking"),
fb("quotio", "gpt-5.3-codex", "high"),
fb("quotio", "claude-sonnet-4-5-thinking"),
...QUALITY_CODING_CHAIN,
]),
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
],
},
metis: {
fallbackChain: dedupeChain([
fb("quotio", "claude-opus-4-6-thinking"),
fb("quotio", "gpt-5.3-codex", "high"),
fb("quotio", "claude-sonnet-4-5-thinking"),
...QUALITY_CODING_CHAIN,
]),
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
],
},
momus: {
fallbackChain: dedupeChain([
fb("quotio", "gpt-5.3-codex", "high"),
fb("quotio", "claude-opus-4-6-thinking"),
...QUALITY_CODING_CHAIN,
]),
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
],
},
atlas: {
fallbackChain: dedupeChain([
fb("quotio", "claude-sonnet-4-5-thinking"),
fb("quotio", "claude-opus-4-6-thinking"),
fb("quotio", "gpt-5.3-codex", "medium"),
...QUALITY_CODING_CHAIN,
]),
fallbackChain: [
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
},
}
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"visual-engineering": {
fallbackChain: [
fb("quotio", "claude-opus-4-6-thinking"),
fb("quotio", "gemini-3-pro-image"),
fb("quotio", "kimi-k2-thinking"),
fb("quotio", "kimi-k2.5"),
fb("quotio", "claude-sonnet-4-5-thinking"),
fb("quotio", "gpt-5.3-codex", "medium"),
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["zai-coding-plan", "opencode"], model: "glm-5" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
],
},
ultrabrain: {
fallbackChain: dedupeChain([
fb("quotio", "gpt-5.3-codex", "high"),
...QUALITY_CODING_CHAIN,
]),
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
],
},
deep: {
fallbackChain: [
fb("quotio", "gpt-5.3-codex", "medium"),
fb("quotio", "claude-opus-4-6-thinking"),
fb("quotio", "claude-sonnet-4-5-thinking"),
...QUALITY_CODING_CHAIN,
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
],
requiresModel: "gpt-5.3-codex",
},
artistry: {
fallbackChain: [
fb("quotio", "claude-opus-4-6-thinking"),
fb("quotio", "claude-sonnet-4-5-thinking"),
fb("quotio", "claude-sonnet-4-5"),
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
requiresModel: "claude-opus-4-6",
requiresModel: "gemini-3-pro",
},
quick: {
fallbackChain: SPEED_CHAIN,
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["opencode"], model: "gpt-5-nano" },
],
},
"unspecified-low": {
fallbackChain: SPEED_CHAIN,
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
],
},
"unspecified-high": {
fallbackChain: dedupeChain([
fb("quotio", "claude-opus-4-6-thinking"),
fb("quotio", "gpt-5.3-codex", "high"),
...QUALITY_CODING_CHAIN,
]),
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
],
},
writing: {
fallbackChain: [
fb("quotio", "claude-sonnet-4-5"),
fb("quotio", "glm-5"),
fb("quotio", "kimi-k2.5"),
fb("quotio", "claude-haiku-4-5"),
fb("quotio", "gemini-3-flash"),
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-6" },
],
},
}

View File

@@ -7,6 +7,7 @@ export type ModelResolutionRequest = {
intent?: {
uiSelectedModel?: string
userModel?: string
userFallbackModels?: string[]
categoryDefaultModel?: string
}
constraints: {
@@ -97,6 +98,42 @@ export function resolveModelPipeline(
})
}
//#when - user configured fallback_models, try them before hardcoded fallback chain
const userFallbackModels = intent?.userFallbackModels
if (userFallbackModels && userFallbackModels.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = constraints.connectedProviders ?? connectedProvidersCache.readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
if (connectedSet !== null) {
for (const model of userFallbackModels) {
attempted.push(model)
const parts = model.split("/")
if (parts.length >= 2) {
const provider = parts[0]
if (connectedSet.has(provider)) {
log("Model resolved via user fallback_models (connected provider)", { model })
return { model, provenance: "provider-fallback", attempted }
}
}
}
log("No connected provider found in user fallback_models, falling through to hardcoded chain")
}
} else {
for (const model of userFallbackModels) {
attempted.push(model)
const parts = model.split("/")
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
const match = fuzzyMatchModel(model, availableModels, providerHint)
if (match) {
log("Model resolved via user fallback_models (availability confirmed)", { model: model, match })
return { model: match, provenance: "provider-fallback", attempted }
}
}
log("No available model found in user fallback_models, falling through to hardcoded chain")
}
}
if (fallbackChain && fallbackChain.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = constraints.connectedProviders ?? connectedProvidersCache.readConnectedProvidersCache()

View File

@@ -22,6 +22,7 @@ export type ModelResolutionResult = {
export type ExtendedModelResolutionInput = {
uiSelectedModel?: string
userModel?: string
userFallbackModels?: string[]
categoryDefaultModel?: string
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
@@ -44,9 +45,9 @@ export function resolveModel(input: ModelResolutionInput): string | undefined {
export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
): ModelResolutionResult | undefined {
const { uiSelectedModel, userModel, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input
const { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input
const resolved = resolveModelPipeline({
intent: { uiSelectedModel, userModel, categoryDefaultModel },
intent: { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel },
constraints: { availableModels },
policy: { fallbackChain, systemDefaultModel },
})
@@ -61,3 +62,13 @@ export function resolveModelWithFallback(
variant: resolved.variant,
}
}
/**
* Normalizes fallback_models config (which can be string or string[]) to string[]
* Centralized helper to avoid duplicated normalization logic
*/
export function normalizeFallbackModels(models: string | string[] | undefined): string[] | undefined {
if (!models) return undefined
if (typeof models === "string") return [models]
return models
}

View File

@@ -0,0 +1,53 @@
/**
* Session Category Registry
*
* Maintains a mapping of session IDs to their assigned categories.
* Used by runtime-fallback hook to lookup category-specific fallback_models.
*/
// Map of sessionID -> category name
const sessionCategoryMap = new Map<string, string>()
export const SessionCategoryRegistry = {
/**
* Register a session with its category
*/
register: (sessionID: string, category: string): void => {
sessionCategoryMap.set(sessionID, category)
},
/**
* Get the category for a session
*/
get: (sessionID: string): string | undefined => {
return sessionCategoryMap.get(sessionID)
},
/**
* Remove a session from the registry (cleanup)
*/
remove: (sessionID: string): void => {
sessionCategoryMap.delete(sessionID)
},
/**
* Check if a session is registered
*/
has: (sessionID: string): boolean => {
return sessionCategoryMap.has(sessionID)
},
/**
* Get the size of the registry (for debugging)
*/
size: (): number => {
return sessionCategoryMap.size
},
/**
* Clear all entries (use with caution, mainly for testing)
*/
clear: (): void => {
sessionCategoryMap.clear()
},
}

View File

@@ -1,30 +0,0 @@
import { describe, expect, test } from "bun:test"
import { clearSessionModel, getSessionModel, setSessionModel } from "./session-model-state"
describe("session-model-state", () => {
test("stores and retrieves a session model", () => {
//#given
const sessionID = "ses_test"
//#when
setSessionModel(sessionID, { providerID: "github-copilot", modelID: "gpt-4.1" })
//#then
expect(getSessionModel(sessionID)).toEqual({
providerID: "github-copilot",
modelID: "gpt-4.1",
})
})
test("clears a session model", () => {
//#given
const sessionID = "ses_clear"
setSessionModel(sessionID, { providerID: "quotio", modelID: "gpt-5.3-codex" })
//#when
clearSessionModel(sessionID)
//#then
expect(getSessionModel(sessionID)).toBeUndefined()
})
})

View File

@@ -1,15 +0,0 @@
export type SessionModel = { providerID: string; modelID: string }
const sessionModels = new Map<string, SessionModel>()
export function setSessionModel(sessionID: string, model: SessionModel): void {
sessionModels.set(sessionID, model)
}
export function getSessionModel(sessionID: string): SessionModel | undefined {
return sessionModels.get(sessionID)
}
export function clearSessionModel(sessionID: string): void {
sessionModels.delete(sessionID)
}

View File

@@ -1,6 +1,6 @@
import type { CallOmoAgentArgs } from "./types"
import type { PluginInput } from "@opencode-ai/plugin"
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
import { subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared"
export async function createOrGetSession(
@@ -64,7 +64,6 @@ Original error: ${createResult.error}`)
const sessionID = createResult.data.id
log(`[call_omo_agent] Created session: ${sessionID}`)
subagentSessions.add(sessionID)
syncSubagentSessions.add(sessionID)
return { sessionID, isNew: true }
}
}

View File

@@ -1,7 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
import { resolveSessionDirectory } from "../../shared"
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
import { subagentSessions } from "../../features/claude-code-session-state"
import type { CallOmoAgentArgs } from "./types"
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
@@ -69,6 +69,5 @@ Original error: ${createResult.error}`,
const sessionID = createResult.data.id
log(`[call_omo_agent] Created session: ${sessionID}`)
subagentSessions.add(sessionID)
syncSubagentSessions.add(sessionID)
return { ok: true, sessionID }
}

View File

@@ -1,10 +1,10 @@
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, ParentContext } from "./executor-types"
import type { FallbackEntry } from "../../shared/model-requirements"
import { getTimingConfig } from "./timing"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
export async function executeBackgroundTask(
args: DelegateTaskArgs,
@@ -13,8 +13,7 @@ export async function executeBackgroundTask(
parentContext: ParentContext,
agentToUse: string,
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
systemContent: string | undefined,
fallbackChain?: FallbackEntry[],
systemContent: string | undefined
): Promise<string> {
const { manager } = executorCtx
@@ -29,7 +28,6 @@ export async function executeBackgroundTask(
parentAgent: parentContext.agent,
parentTools: getSessionTools(parentContext.sessionID),
model: categoryModel,
fallbackChain,
skills: args.load_skills.length > 0 ? args.load_skills : undefined,
skillContent: systemContent,
category: args.category,
@@ -51,6 +49,10 @@ export async function executeBackgroundTask(
sessionId = updated?.sessionID
}
if (args.category && sessionId) {
SessionCategoryRegistry.register(sessionId, args.category)
}
const unstableMeta = {
title: args.description,
metadata: {

View File

@@ -1,7 +1,6 @@
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
import type { DelegateTaskArgs } from "./types"
import type { ExecutorContext } from "./executor-types"
import type { FallbackEntry } from "../../shared/model-requirements"
import { mergeCategories } from "../../shared/merge-categories"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { resolveCategoryConfig } from "./categories"
@@ -17,7 +16,6 @@ export interface CategoryResolutionResult {
modelInfo: ModelFallbackInfo | undefined
actualModel: string | undefined
isUnstableAgent: boolean
fallbackChain?: FallbackEntry[] // For runtime retry on model errors
error?: string
}
@@ -179,6 +177,5 @@ Available categories: ${categoryNames.join(", ")}`,
modelInfo,
actualModel,
isUnstableAgent,
fallbackChain: requirement?.fallbackChain,
}
}

View File

@@ -8,7 +8,6 @@ import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-displ
import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger"
import { getAvailableModelsForDelegateTask } from "./available-models"
import type { FallbackEntry } from "../../shared/model-requirements"
import { resolveModelForDelegateTask } from "./model-selection"
export async function resolveSubagentExecution(
@@ -16,7 +15,7 @@ export async function resolveSubagentExecution(
executorCtx: ExecutorContext,
parentAgent: string | undefined,
categoryExamples: string
): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; fallbackChain?: FallbackEntry[]; error?: string }> {
): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; error?: string }> {
const { client, agentOverrides } = executorCtx
if (!args.subagent_type?.trim()) {
@@ -47,7 +46,6 @@ Create the work plan directly - that's your job as the planning agent.`,
let agentToUse = agentName
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
let fallbackChain: FallbackEntry[] | undefined = undefined
try {
const agentsResult = await client.app.agents()
@@ -94,7 +92,6 @@ Create the work plan directly - that's your job as the planning agent.`,
const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides]
?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1] : undefined)
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey]
fallbackChain = agentRequirement?.fallbackChain
if (agentOverride?.model || agentRequirement || matchedAgent.model) {
const availableModels = await getAvailableModelsForDelegateTask(client)
@@ -138,5 +135,5 @@ Create the work plan directly - that's your job as the planning agent.`,
}
}
return { agentToUse, categoryModel, fallbackChain }
return { agentToUse, categoryModel }
}

View File

@@ -100,7 +100,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
//#when - executeSyncTask with fetchSyncResult failing
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
}, "test-agent", undefined, undefined, undefined, deps)
//#then - should return error and cleanup resources
expect(result).toBe("Fetch failed")
@@ -150,7 +150,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
//#when - executeSyncTask with pollSyncSession failing
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
}, "test-agent", undefined, undefined, undefined, deps)
//#then - should return error and cleanup resources
expect(result).toBe("Poll error")
@@ -200,7 +200,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
//#when - executeSyncTask completes successfully
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
}, "test-agent", undefined, undefined, undefined, deps)
//#then - should complete and cleanup resources
expect(result).toContain("Task completed")

View File

@@ -3,12 +3,12 @@ import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, ParentContext } from "./executor-types"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { subagentSessions, syncSubagentSessions, setSessionAgent } from "../../features/claude-code-session-state"
import { subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting"
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
import { setSessionFallbackChain, clearSessionFallbackChain } from "../../hooks/model-fallback/hook"
export async function executeSyncTask(
args: DelegateTaskArgs,
@@ -19,7 +19,6 @@ export async function executeSyncTask(
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
systemContent: string | undefined,
modelInfo?: ModelFallbackInfo,
fallbackChain?: import("../../shared/model-requirements").FallbackEntry[],
deps: SyncTaskDeps = syncTaskDeps
): Promise<string> {
const { client, directory, onSyncSessionCreated } = executorCtx
@@ -42,9 +41,10 @@ export async function executeSyncTask(
const sessionID = createSessionResult.sessionID
syncSessionID = sessionID
subagentSessions.add(sessionID)
syncSubagentSessions.add(sessionID)
setSessionAgent(sessionID, agentToUse)
setSessionFallbackChain(sessionID, fallbackChain)
if (args.category) {
SessionCategoryRegistry.register(sessionID, args.category)
}
if (onSyncSessionCreated) {
log("[task] Invoking onSyncSessionCreated callback", { sessionID, parentID: parentContext.sessionID })
@@ -64,7 +64,6 @@ export async function executeSyncTask(
if (toastManager) {
toastManager.addTask({
id: taskId,
sessionID,
description: args.description,
agent: agentToUse,
isBackground: false,
@@ -151,8 +150,7 @@ session_id: ${sessionID}
} finally {
if (syncSessionID) {
subagentSessions.delete(syncSessionID)
syncSubagentSessions.delete(syncSessionID)
clearSessionFallbackChain(syncSessionID)
SessionCategoryRegistry.remove(syncSessionID)
}
}
}

View File

@@ -1044,7 +1044,7 @@ describe("sisyphus-task", () => {
modelID: "claude-opus-4-6",
variant: "max",
})
})
}, { timeout: 20000 })
test("DEFAULT_CATEGORIES variant passes to sync session.prompt WITHOUT userCategories", async () => {
// given - NO userCategories, testing DEFAULT_CATEGORIES for sync mode
@@ -2624,31 +2624,35 @@ describe("sisyphus-task", () => {
toolContext
)
// then - agent-browser skill should be resolved (not in notFound)
// then - agent-browser skill should be resolved
expect(promptBody).toBeDefined()
expect(promptBody.system).toBeDefined()
expect(promptBody.system).toContain("agent-browser")
expect(promptBody.system).toContain("<Category_Context>")
expect(String(promptBody.system).startsWith("<Category_Context>")).toBe(false)
}, { timeout: 20000 })
test("should NOT resolve agent-browser skill when browserProvider is not set", async () => {
// given - task without browserProvider (defaults to playwright)
test("should resolve agent-browser skill even when browserProvider is not set", async () => {
// given - delegate_task without browserProvider
const { createDelegateTask } = require("./tools")
let promptBody: any
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_no_browser_provider" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }]
}),
status: async () => ({ data: {} }),
},
}
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_no_browser_provider" } }),
prompt: async (input: any) => {
promptBody = input.body
return { data: {} }
},
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }]
}),
status: async () => ({ data: {} }),
},
}
// No browserProvider passed
const tool = createDelegateTask({
@@ -2675,7 +2679,7 @@ describe("sisyphus-task", () => {
toolContext
)
// then - should return skill not found error
// then - agent-browser skill should NOT resolve without browserProvider
expect(result).toContain("Skills not found")
expect(result).toContain("agent-browser")
})

View File

@@ -164,7 +164,6 @@ Prompts MUST be in English.`
let modelInfo: import("../../features/task-toast-manager/types").ModelFallbackInfo | undefined
let actualModel: string | undefined
let isUnstableAgent = false
let fallbackChain: import("../../shared/model-requirements").FallbackEntry[] | undefined
if (args.category) {
const resolution = await resolveCategoryExecution(args, options, inheritedModel, systemDefaultModel)
@@ -177,7 +176,6 @@ Prompts MUST be in English.`
modelInfo = resolution.modelInfo
actualModel = resolution.actualModel
isUnstableAgent = resolution.isUnstableAgent
fallbackChain = resolution.fallbackChain
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
@@ -208,7 +206,6 @@ Prompts MUST be in English.`
}
agentToUse = resolution.agentToUse
categoryModel = resolution.categoryModel
fallbackChain = resolution.fallbackChain
}
const systemContent = buildSystemContent({
@@ -220,10 +217,10 @@ Prompts MUST be in English.`
})
if (runInBackground) {
return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain)
return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent)
}
return executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain)
return executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo)
},
})
}