@dylanrussell/agent-router 1.0.7 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,7 +105,7 @@ The everyday loop: tune your agents until you like them → `capture <name>` →
105
105
 
106
106
  ## Stacks
107
107
 
108
- A stack file needs one thing: an `agents` record whose entries carry a `model` string. Agent names are the `.md` basenames in your agents dir (`Omni` ↔ `Omni.md`). Any **other key** on an entry is a provider pass-through option (`reasoningEffort`, `thinking`, `temperature`, `topP`, `maxOutputTokens`, …) that opencode forwards to the model — agent-router transcribes these to the agent's frontmatter alongside `model:` and captures them back. Unknown keys are preserved round-trip.
108
+ A stack file needs one thing: an `agents` record whose entries carry a `model` string. Agent names are the `.md` basenames in your agents dir (`Omni` ↔ `Omni.md`). Except for router-owned `fallbacks`, other keys on an entry are provider pass-through options (`reasoningEffort`, `thinking`, `temperature`, `topP`, `maxOutputTokens`, …) that opencode forwards to the model — agent-router transcribes these to the agent's frontmatter alongside `model:` and captures them back. Unknown keys are preserved round-trip.
109
109
 
110
110
  ```json
111
111
  {
@@ -127,6 +127,62 @@ A stack file needs one thing: an `agents` record whose entries carry a `model` s
127
127
 
128
128
  `capture` is the inverse: it reads the current `model:` line and option keys of every agent file (files without a `model:` line are skipped) and writes a stack. There are no bundled seed stacks — your real setup is the seed.
129
129
 
130
+ ## Ordered Fallbacks
131
+
132
+ Optional per-agent fallback chains select a model for the **next explicitly submitted user turn**, not an automatic retry of the failed turn. Enable by adding `fallbacks` to a stack, applying that stack, and restarting opencode with the server plugin loaded:
133
+
134
+ ```json
135
+ {
136
+ "agents": {
137
+ "oracle": {
138
+ "model": "openai/gpt-5.5",
139
+ "variant": "high",
140
+ "fallbacks": [
141
+ { "model": "anthropic/claude-sonnet-4-6", "variant": "low" },
142
+ { "model": "openai/gpt-5.4-mini" }
143
+ ]
144
+ }
145
+ }
146
+ }
147
+ ```
148
+
149
+ Model IDs and variants above are illustrative; use models and variants supported by your providers.
150
+
151
+ - `model` remains the primary. Primary `variant` is an optional nonempty string; `null` clears it on apply.
152
+ - `fallbacks` is optional, or an array of 0 to 8 objects. Each object contains only `model` (non-whitespace `provider/model`) and optional `variant` (nonempty string). `null`, bare strings, unknown candidate fields, and longer chains are rejected.
153
+ - Candidate order is preserved. Exact `(model, variant)` duplicates, including the primary, are skipped at runtime. An omitted fallback variant clears the previous candidate's variant.
154
+ - Only model and variant change at runtime. Agent prompts, permissions, tools, and other provider options remain unchanged; ensure those options are compatible with every candidate.
155
+ - Validation checks every primary and fallback ID, reporting paths such as `agents.oracle.fallbacks.0.model`. It checks catalogue membership through `opencode models`, not live provider health, credentials, quota, or variant support. `validate --active` includes applied fallback metadata when its primary still matches frontmatter.
156
+ - Failover does not rewrite frontmatter, stacks, history, or router state. `use` stores the applied chains in optional `state.json.fallbackAgents`. Old state files without that field remain valid and have no enabled chains until the stack is reapplied.
157
+ - `capture` and displaced-history snapshots merge those applied chains with live frontmatter only when primary model and variant still match. Editing a stack alone does not alter applied metadata. Applying a stack without chains clears the applied chains, including agents omitted from that stack.
158
+ - `back` retains its existing semantics: reapply a previous **named stack as it exists now**, not restore historical snapshot bytes. Runtime failover never adds a history entry.
159
+
160
+ ### Runtime Behavior And Limits
161
+
162
+ The plugin watches correlated assistant `message.updated` errors, requiring the current user-message parent ID, agent, and selected provider/model. It stages the next candidate only for structured `APIError` with `isRetryable: true` and HTTP **429, 500, 502, 503, or 504**. Native opencode retries may occur before the terminal error reaches this hook. Router does not change their policy or timing.
163
+
164
+ Authentication/permission errors, context overflow, content filtering, output-length errors, unknown errors, transport errors without an HTTP status, and ambiguous `session.error` notifications do not advance the chain. Router never classifies errors by substring matching.
165
+
166
+ After a qualifying failure a 15-second warning toast (or log fallback) names the next model and asks for an explicit retry/continue instruction. Every notice is also logged under `agent-router`. **Review partial output and completed tools before continuing.** The mutable `chat.message` hook selects the fallback when the next prompt is admitted; no prompt is submitted by this plugin. The successful fallback stays selected for subsequent turns. Chains advance monotonically with at most eight transitions per agent/session; exhaustion produces a notice, never wraps to the primary. There are no timers, background retries, or automatic failback.
167
+
168
+ Observed cancellation (`MessageAbortedError`), explicit model/agent switch events, and an incoming model/variant different from both the configured primary and current candidate disable routing for that session until restart. A switch to the same model is distinguishable only when the host emits its switch event. Hosts/UI paths that omit both the event and a changed selection cannot expose that manual intent to this plugin. Switching stacks with router tools disables running failover immediately; external CLI/TUI state changes disable it at the next prompt admission. Restart to activate the newly applied stack.
169
+
170
+ Duplicate/concurrent error events advance only once per user turn. Selection and bookkeeping run synchronously before notification awaits, so delayed notices cannot overwrite cancellation/manual selection. Sessions and per-agent budgets are isolated; deleted/disabled sessions remain tombstones to prevent rearming. After 1,024 tracked sessions in one plugin instance, additional sessions receive no automatic routing until restart. Untracked sessions and subagents that bypass `chat.message` are deliberately left alone.
171
+
172
+ **Verified boundary:** inspected opencode **v1.17.15** `packages/opencode/src/session/prompt.ts`: `chat.message` mutates the message before `sessions.updateMessage(info)` and the legacy session loop resolves `lastUser.model`. A deterministic in-process provider harness tests those hook semantics, explicit next-turn continuation, variant selection, and no replay/persistent writes. This is **not a real-server end-to-end provider test** and does not prove that every new v2 execution path invokes these legacy hooks. Hosts that bypass them receive no failover. There is no transparent same-turn continuation guarantee, no automatic recovery of a failed `task` invocation, and no guarantee that an LLM will not choose to repeat a tool when explicitly asked to continue.
173
+
174
+ The installed SDK exposes `/v2` `client.v2.session.switchModel` for subsequent turns, but no atomic compare-and-switch/retry contract was verified. Router deliberately does **not** call it: an asynchronous model write could race a manual selection or cancellation. No direct SDK dependency is needed; plugin hooks are the integration boundary. The TUI model picker and router status still display stack/primary assignments, not session-local runtime candidates.
175
+
176
+ ### Install This Checkout
177
+
178
+ Ordered fallbacks require agent-router **1.1.0 or later**. To test a local checkout instead of the published package, run `npm run typecheck`, `npm test`, and `npm run build` using the installed dependencies (a fresh checkout uses `pnpm install --frozen-lockfile`). Replace `/absolute/path/to/agent-router` below with your checkout path, then replace the registry router entry in the **server** `opencode.json` plugin array with:
179
+
180
+ ```json
181
+ "file:///absolute/path/to/agent-router/dist/plugin.js"
182
+ ```
183
+
184
+ Do not load both registry and local server plugins. If using the router TUI plugin, also replace its registry entry in `tui.json` with `file:///absolute/path/to/agent-router/dist/tui.js`. Use the checkout's CLI too: older router builds strictly validate state and cannot read the new `fallbackAgents` field. Add a chain to your stack and run `node /absolute/path/to/agent-router/dist/cli.js use <stack-name>`, then quit and restart opencode. These installation/configuration steps are instructions only; implementation and tests do not modify your live agent configuration. Start a new session, and confirm you see the explicit next-turn notice after a qualifying terminal error. Headless clients must inspect the `agent-router` log.
185
+
130
186
  ## Inside opencode
131
187
 
132
188
  The plugin exposes six tools the agent (or you, by asking it) can call:
package/dist/cli.js CHANGED
@@ -15244,19 +15244,31 @@ var init_zod = __esm({
15244
15244
  });
15245
15245
 
15246
15246
  // src/core/schema.ts
15247
- var StateFileSchema, AgentEntrySchema, StackFileSchema, ConfigFileSchema, OpencodeJsonSchema;
15247
+ var FallbackSchema, RoutingEntrySchema, StateFileSchema, AgentEntrySchema, StackFileSchema, ConfigFileSchema, OpencodeJsonSchema;
15248
15248
  var init_schema = __esm({
15249
15249
  "src/core/schema.ts"() {
15250
15250
  "use strict";
15251
15251
  init_zod();
15252
+ FallbackSchema = external_exports.object({
15253
+ model: external_exports.string().regex(/^[^/\s]+\/\S+$/, "Expected provider/model"),
15254
+ variant: external_exports.string().min(1).optional()
15255
+ }).strict();
15256
+ RoutingEntrySchema = external_exports.object({
15257
+ model: external_exports.string().min(1),
15258
+ variant: external_exports.string().min(1).nullable().optional(),
15259
+ fallbacks: external_exports.array(FallbackSchema).max(8).optional()
15260
+ }).strict();
15252
15261
  StateFileSchema = external_exports.object({
15253
15262
  version: external_exports.literal(1),
15254
15263
  active: external_exports.string().min(1),
15255
15264
  previousActive: external_exports.string().min(1).nullable(),
15256
- lastSwitchedAt: external_exports.string().min(1)
15265
+ lastSwitchedAt: external_exports.string().min(1),
15266
+ fallbackAgents: external_exports.record(external_exports.string(), RoutingEntrySchema).optional()
15257
15267
  }).strict();
15258
15268
  AgentEntrySchema = external_exports.object({
15259
- model: external_exports.string().min(1)
15269
+ model: external_exports.string().min(1),
15270
+ variant: external_exports.string().min(1).nullable().optional(),
15271
+ fallbacks: external_exports.array(FallbackSchema).max(8).optional()
15260
15272
  }).passthrough();
15261
15273
  StackFileSchema = external_exports.object({
15262
15274
  agents: external_exports.record(external_exports.string(), AgentEntrySchema)
@@ -16697,6 +16709,9 @@ function collectModelRefs(stack) {
16697
16709
  const refs = [];
16698
16710
  for (const [k, v] of Object.entries(stack.agents)) {
16699
16711
  refs.push({ path: `agents.${k}.model`, modelId: v.model });
16712
+ for (const [index, candidate] of (v.fallbacks ?? []).entries()) {
16713
+ refs.push({ path: `agents.${k}.fallbacks.${index}.model`, modelId: candidate.model });
16714
+ }
16700
16715
  }
16701
16716
  return refs;
16702
16717
  }
@@ -16783,7 +16798,7 @@ async function applyStack(paths, name, options = {}) {
16783
16798
  }
16784
16799
  const prevState = await readState(paths.statePath);
16785
16800
  const prevActive = prevState?.active ?? null;
16786
- const displaced = { agents: entriesToStackAgents(await readAgentEntries(paths.agentsDir)) };
16801
+ const displaced = { agents: await captureAgents(paths) };
16787
16802
  const historyId = await appendHistory(
16788
16803
  paths.historyDir,
16789
16804
  prevActive ?? "(none)",
@@ -16801,7 +16816,20 @@ async function applyStack(paths, name, options = {}) {
16801
16816
  version: 1,
16802
16817
  active: name,
16803
16818
  previousActive: prevActive,
16804
- lastSwitchedAt: (/* @__PURE__ */ new Date()).toISOString()
16819
+ lastSwitchedAt: (/* @__PURE__ */ new Date()).toISOString(),
16820
+ fallbackAgents: Object.fromEntries(
16821
+ Object.entries(target.agents).filter(([, entry]) => entry.fallbacks?.length).map(([agent, entry]) => {
16822
+ const variant = "variant" in entry ? entry.variant : displaced.agents[agent]?.variant;
16823
+ return [
16824
+ agent,
16825
+ {
16826
+ model: entry.model,
16827
+ variant: typeof variant === "string" ? variant : void 0,
16828
+ fallbacks: entry.fallbacks
16829
+ }
16830
+ ];
16831
+ })
16832
+ )
16805
16833
  });
16806
16834
  await trimHistory(paths.historyDir).catch(() => {
16807
16835
  });
@@ -16817,7 +16845,7 @@ function entryOptions(entry) {
16817
16845
  const rec = entry;
16818
16846
  const out = {};
16819
16847
  for (const [k, v] of Object.entries(rec)) {
16820
- if (k === "model" || RESERVED_AGENT_KEYS.has(k)) continue;
16848
+ if (k === "model" || k === "fallbacks" || RESERVED_AGENT_KEYS.has(k)) continue;
16821
16849
  out[k] = v;
16822
16850
  }
16823
16851
  return out;
@@ -16829,11 +16857,24 @@ function entriesToStackAgents(entries) {
16829
16857
  if (!entry) continue;
16830
16858
  const { model, options } = entry;
16831
16859
  const stackEntry = { model };
16832
- for (const [k, v] of Object.entries(options)) stackEntry[k] = v;
16860
+ for (const [k, v] of Object.entries(options)) {
16861
+ if (k !== "fallbacks") stackEntry[k] = v;
16862
+ }
16833
16863
  out[name] = stackEntry;
16834
16864
  }
16835
16865
  return out;
16836
16866
  }
16867
+ async function captureAgents(paths) {
16868
+ const agents = entriesToStackAgents(await readAgentEntries(paths.agentsDir));
16869
+ const state = await readState(paths.statePath);
16870
+ for (const [name, entry] of Object.entries(agents)) {
16871
+ const routing = state?.fallbackAgents?.[name];
16872
+ if (routing && routing.model === entry.model && (routing.variant ?? void 0) === (entry.variant ?? void 0)) {
16873
+ entry.fallbacks = routing.fallbacks;
16874
+ }
16875
+ }
16876
+ return agents;
16877
+ }
16837
16878
  async function back(paths, n = 1, options = {}) {
16838
16879
  if (n < 1) throw new UserError("`back -n` must be at least 1.");
16839
16880
  const state = await readState(paths.statePath);
@@ -16867,8 +16908,7 @@ async function captureStack(paths, name, options = {}) {
16867
16908
  if (existsSync6(dest) && !options.force) {
16868
16909
  throw new UserError(`Stack "${name}" already exists. Use --force to overwrite.`);
16869
16910
  }
16870
- const entries = await readAgentEntries(paths.agentsDir);
16871
- const agents = entriesToStackAgents(entries);
16911
+ const agents = await captureAgents(paths);
16872
16912
  if (Object.keys(agents).length === 0) {
16873
16913
  throw new UserError(
16874
16914
  `No agent .md files with a frontmatter \`model:\` line found in ${paths.agentsDir}.`
@@ -16911,7 +16951,7 @@ async function exportStack(paths, name, toFile) {
16911
16951
  }
16912
16952
 
16913
16953
  // src/version.ts
16914
- var VERSION = "1.0.7";
16954
+ var VERSION = "1.1.0";
16915
16955
 
16916
16956
  // src/cli.ts
16917
16957
  var log = (...args) => console.log(...args);
@@ -17066,11 +17106,7 @@ async function cmdValidate(paths, name, opts) {
17066
17106
  targets.push({
17067
17107
  name: "(current frontmatter)",
17068
17108
  load: async () => {
17069
- const models = await readAgentModels(paths.agentsDir);
17070
- const agents = Object.fromEntries(
17071
- Object.entries(models).map(([k, model]) => [k, { model }])
17072
- );
17073
- return { agents };
17109
+ return { agents: await captureAgents(paths) };
17074
17110
  }
17075
17111
  });
17076
17112
  } else if (opts.all) {