fix: sync alias agents to core config so delegation resolves correctly#452
fix: sync alias agents to core config so delegation resolves correctly#452hanbinnoh wants to merge 4 commits into
Conversation
When opencode core registers a built-in agent that shares a name with a plugin alias (e.g. core's native "explore" and the plugin's "explorer"), the two coexist as separate agents. Delegation to the alias picks the core's built-in (which has no configured model) and falls back to the parent model. Now, after merging plugin agents into opencodeConfig.agent, we synchronize each alias to its target: copy the target's config (including model/variant/temperature) and mark the alias hidden. This ensures: - @explore resolves to the same model as @explorer - Alias entries are hidden from the TUI sidebar - User-configured alias models are respected (not overwritten)
Greptile SummaryThis PR adds a sync step in the
Confidence Score: 3/5The alias-sync logic is placed too early in the config pipeline; aliases capture a stale snapshot of their target before the model-array resolver and runtime-preset block run, so both advanced features will still produce alias/target divergence. The alias sync correctly handles the simplest case (no model arrays, no runtime preset) where it does fix the core delegation issue. However, it runs at the wrong point in the config() pipeline: model-array resolution and runtime-preset overrides both mutate the target entry afterward without touching the alias. Combined with the sticky-guard issue that prevents re-syncing on subsequent config() calls, the fix is unreliable in the two scenarios that are most likely in production use of this plugin. src/index.ts — specifically the ordering of the new alias-sync block relative to the effectiveArrays resolution loop (lines 500–534) and the runtime-preset override block (lines 541–593). Important Files Changed
Sequence DiagramsequenceDiagram
participant Hook as config() hook
participant CA as configAgent
participant MAR as Model Array Resolution
participant RPO as Runtime Preset Override
Hook->>CA: Merge plugin agents into opencodeConfig.agent
Note over Hook,CA: explorer.model = baseline-model
Hook->>CA: "Alias sync (NEW): configAgent[explore] = { ...explorer, hidden: true }"
Note over Hook,CA: explore.model = baseline-model (snapshot taken here)
Hook->>MAR: Resolve _modelArray, pick first available model
MAR->>CA: "configAgent[explorer].model = resolved-model"
Note over CA: explorer.model = resolved-model, explore.model = baseline-model (stale)
Hook->>RPO: Apply active runtime preset
RPO->>CA: "configAgent[explorer].model = preset-model"
Note over CA: explorer.model = preset-model, explore.model = baseline-model (still stale)
Reviews (1): Last reviewed commit: "fix: sync alias agents to core config so..." | Re-trigger Greptile |
| // Synchronize alias agents with their target agents so that | ||
| // delegation via alias names (e.g. @explore) resolves to the | ||
| // same model/settings as the target agent (e.g. @explorer). | ||
| // This also ensures aliases override any core built-in agents | ||
| // that share the same name (e.g. opencode's native "explore"). | ||
| for (const [alias, target] of Object.entries(AGENT_ALIASES)) { | ||
| const targetEntry = configAgent[target] as | ||
| | Record<string, unknown> | ||
| | undefined; | ||
| if (!targetEntry) continue; | ||
| // If the alias already has a user-configured model, respect it | ||
| const aliasEntry = configAgent[alias] as | ||
| | Record<string, unknown> | ||
| | undefined; | ||
| if (aliasEntry?.model) continue; | ||
| configAgent[alias] = { | ||
| ...targetEntry, | ||
| hidden: true, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Alias sync runs before model-array resolution and runtime-preset override
The alias sync block snapshots targetEntry from configAgent[target] before the model-array resolution loop (lines 500–534) and the runtime-preset override block (lines 541–593) have run. Both of those later blocks mutate configAgent[target].model in-place, but the already-written alias entry is never touched afterward.
Concrete failure path: if explorer has a _modelArray configured, the model-resolution loop will pick the first available model and write it to configAgent['explorer'].model — but configAgent['explore'] was snapshotted before that and keeps the stale baseline model. The same desync occurs when an active runtime preset overrides explorer's model via the AGENT_ALIASES-resolved entry at line 547: configAgent['explorer'] gets the preset model while configAgent['explore'] still holds the pre-preset value.
Moving the alias-sync loop to run after both the effectiveArrays resolution block and the runtime-preset block (i.e., after line ~593) would ensure the alias always reflects the fully-resolved target state.
| const aliasEntry = configAgent[alias] as | ||
| | Record<string, unknown> | ||
| | undefined; | ||
| if (aliasEntry?.model) continue; | ||
| configAgent[alias] = { | ||
| ...targetEntry, | ||
| hidden: true, | ||
| }; |
There was a problem hiding this comment.
Guard
aliasEntry?.model makes the alias permanently sticky after the first sync
Once configAgent[alias] is written with { ...targetEntry, hidden: true }, subsequent invocations of the config() hook on the same persisted opencodeConfig object will find aliasEntry.model truthy and skip re-syncing entirely. If the target's model later changes (e.g., the user edits their config, a runtime preset is applied, or a model-array resolves differently), the alias keeps the value from the very first sync call and diverges from the target — the exact problem this PR aims to fix.
A more robust guard would compare the alias's model against the current target model rather than simply testing for the presence of any model, or the alias-sync loop should be idempotent by always overwriting when no explicit user entry exists for the alias key in the raw config (before any previous sync wrote to it).
| configAgent[alias] = { | ||
| ...targetEntry, | ||
| hidden: true, | ||
| }; |
There was a problem hiding this comment.
Spreading
targetEntry copies all agent fields, not just model-related ones
{ ...targetEntry, hidden: true } copies every property the target carries — tools, instructions, system_prompt, MCPs, permission lists, etc. — into the alias entry. If the core subsequently reads configAgent['explore'] and acts on those fields (e.g., tool allowlists, custom instructions), the alias will silently behave like a full clone of the target rather than a thin model-routing shim. The intended contract appears to be model/variant/temperature parity only; selectively copying just those three fields would make the alias safer and easier to reason about.
Three issues from review: 1. Sync ran before model-array resolution and runtime-preset overrides, causing aliases to capture stale target models. 2. aliasEntry?.model guard made aliases sticky after first config() call. 3. ...targetEntry spread copied all fields (tools, MCPs, instructions). Now the sync runs AFTER all model mutations and only copies model/variant/temperature. It merges into the existing alias entry to preserve user-configured permission/tools fields.
Council review found a stale-field bug: when runtime preset reset deletes temperature/variant from the target agent, the alias sync only conditionally set fields but never deleted them, leaving stale values on the alias. Now uses an explicit set-or-delete pattern: fields present on target are copied, fields absent are deleted from the alias. Also added options sync for parity with the preset override/reset blocks.
Aliases like frontend-ui-ux-engineer have no core built-in
counterpart. Previously the ?? {} fallback would inject ghost agent
configs with no prompt/description. Now we skip aliases that don't
already exist in configAgent, matching the stated intent of
overriding core built-in agents.
|
Thanks @hanbinnoh - during installation we disable builtin agents. |
|
OpenCode detected the issue and modified the config, but I thought there might be others like me who suddenly ended up using expensive models for explorer tasks and wasting tokens, so I updated the code. |
When opencode core registers a built-in agent that shares a name with a plugin alias (e.g. core's native "explore" and the plugin's "explorer"), the two coexist as separate agents. Delegation to the alias picks the core's built-in (which has no configured model) and falls back to the parent model.
Now, after merging plugin agents into opencodeConfig.agent, we synchronize each alias to its target: copy the target's config (including model/variant/temperature) and mark the alias hidden. This ensures:
What changed, and why was it needed?