@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 +57 -1
- package/dist/cli.js +51 -15
- package/dist/cli.js.map +1 -1
- package/dist/plugin.js +239 -15
- package/dist/plugin.js.map +1 -1
- package/dist/tui.js +47 -6
- package/dist/tui.js.map +1 -1
- package/package.json +1 -1
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`).
|
|
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:
|
|
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))
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|