@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/dist/plugin.js
CHANGED
|
@@ -5,6 +5,7 @@ var __export = (target, all) => {
|
|
|
5
5
|
};
|
|
6
6
|
|
|
7
7
|
// src/plugin.ts
|
|
8
|
+
import { readFileSync } from "fs";
|
|
8
9
|
import { tool } from "@opencode-ai/plugin";
|
|
9
10
|
|
|
10
11
|
// src/core/config.ts
|
|
@@ -14618,14 +14619,26 @@ function date4(params) {
|
|
|
14618
14619
|
config(en_default());
|
|
14619
14620
|
|
|
14620
14621
|
// src/core/schema.ts
|
|
14622
|
+
var FallbackSchema = external_exports.object({
|
|
14623
|
+
model: external_exports.string().regex(/^[^/\s]+\/\S+$/, "Expected provider/model"),
|
|
14624
|
+
variant: external_exports.string().min(1).optional()
|
|
14625
|
+
}).strict();
|
|
14626
|
+
var RoutingEntrySchema = external_exports.object({
|
|
14627
|
+
model: external_exports.string().min(1),
|
|
14628
|
+
variant: external_exports.string().min(1).nullable().optional(),
|
|
14629
|
+
fallbacks: external_exports.array(FallbackSchema).max(8).optional()
|
|
14630
|
+
}).strict();
|
|
14621
14631
|
var StateFileSchema = external_exports.object({
|
|
14622
14632
|
version: external_exports.literal(1),
|
|
14623
14633
|
active: external_exports.string().min(1),
|
|
14624
14634
|
previousActive: external_exports.string().min(1).nullable(),
|
|
14625
|
-
lastSwitchedAt: external_exports.string().min(1)
|
|
14635
|
+
lastSwitchedAt: external_exports.string().min(1),
|
|
14636
|
+
fallbackAgents: external_exports.record(external_exports.string(), RoutingEntrySchema).optional()
|
|
14626
14637
|
}).strict();
|
|
14627
14638
|
var AgentEntrySchema = external_exports.object({
|
|
14628
|
-
model: external_exports.string().min(1)
|
|
14639
|
+
model: external_exports.string().min(1),
|
|
14640
|
+
variant: external_exports.string().min(1).nullable().optional(),
|
|
14641
|
+
fallbacks: external_exports.array(FallbackSchema).max(8).optional()
|
|
14629
14642
|
}).passthrough();
|
|
14630
14643
|
var StackFileSchema = external_exports.object({
|
|
14631
14644
|
agents: external_exports.record(external_exports.string(), AgentEntrySchema)
|
|
@@ -14701,6 +14714,157 @@ async function resolvePathsWithConfig(options = {}) {
|
|
|
14701
14714
|
return resolvePaths(next);
|
|
14702
14715
|
}
|
|
14703
14716
|
|
|
14717
|
+
// src/core/failover.ts
|
|
14718
|
+
var Model = external_exports.object({
|
|
14719
|
+
providerID: external_exports.string(),
|
|
14720
|
+
modelID: external_exports.string(),
|
|
14721
|
+
variant: external_exports.string().optional()
|
|
14722
|
+
});
|
|
14723
|
+
var ErrorInfo = external_exports.object({
|
|
14724
|
+
name: external_exports.string(),
|
|
14725
|
+
data: external_exports.object({ statusCode: external_exports.number().optional(), isRetryable: external_exports.boolean().optional() }).passthrough()
|
|
14726
|
+
});
|
|
14727
|
+
var Event = external_exports.object({
|
|
14728
|
+
type: external_exports.string(),
|
|
14729
|
+
properties: external_exports.object({
|
|
14730
|
+
sessionID: external_exports.string().optional(),
|
|
14731
|
+
error: ErrorInfo.optional(),
|
|
14732
|
+
info: external_exports.object({
|
|
14733
|
+
id: external_exports.string().optional(),
|
|
14734
|
+
sessionID: external_exports.string().optional(),
|
|
14735
|
+
role: external_exports.string().optional(),
|
|
14736
|
+
parentID: external_exports.string().optional(),
|
|
14737
|
+
agent: external_exports.string().optional(),
|
|
14738
|
+
providerID: external_exports.string().optional(),
|
|
14739
|
+
modelID: external_exports.string().optional(),
|
|
14740
|
+
variant: external_exports.string().optional(),
|
|
14741
|
+
error: ErrorInfo.optional()
|
|
14742
|
+
}).passthrough().optional()
|
|
14743
|
+
}).passthrough()
|
|
14744
|
+
});
|
|
14745
|
+
function selection(entry) {
|
|
14746
|
+
const slash = entry.model.indexOf("/");
|
|
14747
|
+
return {
|
|
14748
|
+
providerID: entry.model.slice(0, slash),
|
|
14749
|
+
modelID: entry.model.slice(slash + 1),
|
|
14750
|
+
variant: entry.variant ?? void 0
|
|
14751
|
+
};
|
|
14752
|
+
}
|
|
14753
|
+
function same(a, b) {
|
|
14754
|
+
return a.providerID === b.providerID && a.modelID === b.modelID && a.variant === b.variant;
|
|
14755
|
+
}
|
|
14756
|
+
function createFailover(routes, notice) {
|
|
14757
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
14758
|
+
let enabled = true;
|
|
14759
|
+
const chains = new Map(
|
|
14760
|
+
Object.entries(routes).map(([agent, entry]) => {
|
|
14761
|
+
const chain = [entry, ...entry.fallbacks ?? []].map(selection);
|
|
14762
|
+
return [
|
|
14763
|
+
agent,
|
|
14764
|
+
chain.filter((value, i) => !chain.slice(0, i).some((previous) => same(previous, value)))
|
|
14765
|
+
];
|
|
14766
|
+
})
|
|
14767
|
+
);
|
|
14768
|
+
const notify = (message) => notice(`agent-router: ${message}`).catch(() => {
|
|
14769
|
+
});
|
|
14770
|
+
return {
|
|
14771
|
+
disable() {
|
|
14772
|
+
enabled = false;
|
|
14773
|
+
sessions.clear();
|
|
14774
|
+
},
|
|
14775
|
+
async message(input, output) {
|
|
14776
|
+
if (!enabled) return;
|
|
14777
|
+
const message = output.message;
|
|
14778
|
+
const chain = chains.get(message.agent);
|
|
14779
|
+
if (!chain || chain.length < 2) {
|
|
14780
|
+
const existing = sessions.get(input.sessionID);
|
|
14781
|
+
if (existing) {
|
|
14782
|
+
existing.turn = void 0;
|
|
14783
|
+
existing.pending = void 0;
|
|
14784
|
+
}
|
|
14785
|
+
return;
|
|
14786
|
+
}
|
|
14787
|
+
let state = sessions.get(input.sessionID);
|
|
14788
|
+
if (!state) {
|
|
14789
|
+
if (sessions.size >= 1024) return;
|
|
14790
|
+
state = { disabled: false, indices: /* @__PURE__ */ new Map() };
|
|
14791
|
+
sessions.set(input.sessionID, state);
|
|
14792
|
+
}
|
|
14793
|
+
if (state.disabled) return;
|
|
14794
|
+
const current = chain[state.indices.get(message.agent) ?? 0];
|
|
14795
|
+
const primary = chain[0];
|
|
14796
|
+
if (!current || !primary) return;
|
|
14797
|
+
const incoming = message.model;
|
|
14798
|
+
const previous = state.turn?.agent === message.agent ? state.turn : void 0;
|
|
14799
|
+
const pending = previous ? state.pending : void 0;
|
|
14800
|
+
if (!same(incoming, current) && !same(incoming, primary) && !(pending && previous && same(incoming, previous.model))) {
|
|
14801
|
+
state.disabled = true;
|
|
14802
|
+
state.pending = void 0;
|
|
14803
|
+
return;
|
|
14804
|
+
}
|
|
14805
|
+
state.pending = void 0;
|
|
14806
|
+
if (pending) message.model = { ...pending };
|
|
14807
|
+
else message.model = { ...current };
|
|
14808
|
+
state.turn = {
|
|
14809
|
+
agent: message.agent,
|
|
14810
|
+
parent: message.id,
|
|
14811
|
+
model: { ...message.model },
|
|
14812
|
+
handled: false
|
|
14813
|
+
};
|
|
14814
|
+
if (pending)
|
|
14815
|
+
await notify(
|
|
14816
|
+
`[${input.sessionID}] selected ${pending.providerID}/${pending.modelID} for this new turn. No previous prompt or tool was replayed.`
|
|
14817
|
+
);
|
|
14818
|
+
},
|
|
14819
|
+
async event(input) {
|
|
14820
|
+
if (!enabled) return;
|
|
14821
|
+
const parsed = Event.safeParse(input.event);
|
|
14822
|
+
if (!parsed.success) return;
|
|
14823
|
+
const { type, properties: p } = parsed.data;
|
|
14824
|
+
const sessionID = p.sessionID ?? p.info?.sessionID ?? (type === "session.deleted" ? p.info?.id : void 0);
|
|
14825
|
+
if (!sessionID) return;
|
|
14826
|
+
const state = sessions.get(sessionID);
|
|
14827
|
+
if (!state || state.disabled) return;
|
|
14828
|
+
if (type === "session.deleted" || type === "session.next.model.switched" || type === "session.next.agent.switched" || type === "session.error" && p.error?.name === "MessageAbortedError") {
|
|
14829
|
+
state.disabled = true;
|
|
14830
|
+
state.pending = void 0;
|
|
14831
|
+
return;
|
|
14832
|
+
}
|
|
14833
|
+
if (type !== "message.updated") return;
|
|
14834
|
+
const info = p.info;
|
|
14835
|
+
const turn = state.turn;
|
|
14836
|
+
if (!info || !turn || info.role !== "assistant" || info.parentID !== turn.parent || info.agent !== turn.agent || info.providerID !== turn.model.providerID || info.modelID !== turn.model.modelID || !info.error)
|
|
14837
|
+
return;
|
|
14838
|
+
if (info.error.name === "MessageAbortedError") {
|
|
14839
|
+
state.disabled = true;
|
|
14840
|
+
state.pending = void 0;
|
|
14841
|
+
return;
|
|
14842
|
+
}
|
|
14843
|
+
if (turn.handled) return;
|
|
14844
|
+
turn.handled = true;
|
|
14845
|
+
const error51 = info.error;
|
|
14846
|
+
const status = error51.data.statusCode;
|
|
14847
|
+
if (error51.name !== "APIError" || !error51.data.isRetryable || status === void 0 || ![429, 500, 502, 503, 504].includes(status))
|
|
14848
|
+
return;
|
|
14849
|
+
const chain = chains.get(turn.agent);
|
|
14850
|
+
if (!chain) return;
|
|
14851
|
+
const index = (state.indices.get(turn.agent) ?? 0) + 1;
|
|
14852
|
+
const next = chain[index];
|
|
14853
|
+
if (!next) {
|
|
14854
|
+
await notify(
|
|
14855
|
+
`[${sessionID}] fallback chain exhausted for ${turn.agent}. Choose a model manually; no retry was sent.`
|
|
14856
|
+
);
|
|
14857
|
+
return;
|
|
14858
|
+
}
|
|
14859
|
+
state.indices.set(turn.agent, index);
|
|
14860
|
+
state.pending = next;
|
|
14861
|
+
await notify(
|
|
14862
|
+
`[${sessionID}] ${turn.agent} failed (HTTP ${status}). Next user turn will use ${next.providerID}/${next.modelID}${next.variant ? ` (${next.variant})` : ""}. Send an explicit retry/continue instruction after reviewing partial output and completed tools. Nothing was retried automatically.`
|
|
14863
|
+
);
|
|
14864
|
+
}
|
|
14865
|
+
};
|
|
14866
|
+
}
|
|
14867
|
+
|
|
14704
14868
|
// src/core/frontmatter.ts
|
|
14705
14869
|
import { existsSync as existsSync2 } from "fs";
|
|
14706
14870
|
import { readFile as readFile2, readdir } from "fs/promises";
|
|
@@ -15312,6 +15476,9 @@ function collectModelRefs(stack) {
|
|
|
15312
15476
|
const refs = [];
|
|
15313
15477
|
for (const [k, v] of Object.entries(stack.agents)) {
|
|
15314
15478
|
refs.push({ path: `agents.${k}.model`, modelId: v.model });
|
|
15479
|
+
for (const [index, candidate] of (v.fallbacks ?? []).entries()) {
|
|
15480
|
+
refs.push({ path: `agents.${k}.fallbacks.${index}.model`, modelId: candidate.model });
|
|
15481
|
+
}
|
|
15315
15482
|
}
|
|
15316
15483
|
return refs;
|
|
15317
15484
|
}
|
|
@@ -15391,7 +15558,7 @@ async function applyStack(paths, name, options = {}) {
|
|
|
15391
15558
|
}
|
|
15392
15559
|
const prevState = await readState(paths.statePath);
|
|
15393
15560
|
const prevActive = prevState?.active ?? null;
|
|
15394
|
-
const displaced = { agents:
|
|
15561
|
+
const displaced = { agents: await captureAgents(paths) };
|
|
15395
15562
|
const historyId = await appendHistory(
|
|
15396
15563
|
paths.historyDir,
|
|
15397
15564
|
prevActive ?? "(none)",
|
|
@@ -15409,7 +15576,20 @@ async function applyStack(paths, name, options = {}) {
|
|
|
15409
15576
|
version: 1,
|
|
15410
15577
|
active: name,
|
|
15411
15578
|
previousActive: prevActive,
|
|
15412
|
-
lastSwitchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
15579
|
+
lastSwitchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
15580
|
+
fallbackAgents: Object.fromEntries(
|
|
15581
|
+
Object.entries(target.agents).filter(([, entry]) => entry.fallbacks?.length).map(([agent, entry]) => {
|
|
15582
|
+
const variant = "variant" in entry ? entry.variant : displaced.agents[agent]?.variant;
|
|
15583
|
+
return [
|
|
15584
|
+
agent,
|
|
15585
|
+
{
|
|
15586
|
+
model: entry.model,
|
|
15587
|
+
variant: typeof variant === "string" ? variant : void 0,
|
|
15588
|
+
fallbacks: entry.fallbacks
|
|
15589
|
+
}
|
|
15590
|
+
];
|
|
15591
|
+
})
|
|
15592
|
+
)
|
|
15413
15593
|
});
|
|
15414
15594
|
await trimHistory(paths.historyDir).catch(() => {
|
|
15415
15595
|
});
|
|
@@ -15425,7 +15605,7 @@ function entryOptions(entry) {
|
|
|
15425
15605
|
const rec = entry;
|
|
15426
15606
|
const out = {};
|
|
15427
15607
|
for (const [k, v] of Object.entries(rec)) {
|
|
15428
|
-
if (k === "model" || RESERVED_AGENT_KEYS.has(k)) continue;
|
|
15608
|
+
if (k === "model" || k === "fallbacks" || RESERVED_AGENT_KEYS.has(k)) continue;
|
|
15429
15609
|
out[k] = v;
|
|
15430
15610
|
}
|
|
15431
15611
|
return out;
|
|
@@ -15437,11 +15617,24 @@ function entriesToStackAgents(entries) {
|
|
|
15437
15617
|
if (!entry) continue;
|
|
15438
15618
|
const { model, options } = entry;
|
|
15439
15619
|
const stackEntry = { model };
|
|
15440
|
-
for (const [k, v] of Object.entries(options))
|
|
15620
|
+
for (const [k, v] of Object.entries(options)) {
|
|
15621
|
+
if (k !== "fallbacks") stackEntry[k] = v;
|
|
15622
|
+
}
|
|
15441
15623
|
out[name] = stackEntry;
|
|
15442
15624
|
}
|
|
15443
15625
|
return out;
|
|
15444
15626
|
}
|
|
15627
|
+
async function captureAgents(paths) {
|
|
15628
|
+
const agents = entriesToStackAgents(await readAgentEntries(paths.agentsDir));
|
|
15629
|
+
const state = await readState(paths.statePath);
|
|
15630
|
+
for (const [name, entry] of Object.entries(agents)) {
|
|
15631
|
+
const routing = state?.fallbackAgents?.[name];
|
|
15632
|
+
if (routing && routing.model === entry.model && (routing.variant ?? void 0) === (entry.variant ?? void 0)) {
|
|
15633
|
+
entry.fallbacks = routing.fallbacks;
|
|
15634
|
+
}
|
|
15635
|
+
}
|
|
15636
|
+
return agents;
|
|
15637
|
+
}
|
|
15445
15638
|
async function back(paths, n = 1, options = {}) {
|
|
15446
15639
|
if (n < 1) throw new UserError("`back -n` must be at least 1.");
|
|
15447
15640
|
const state = await readState(paths.statePath);
|
|
@@ -15475,8 +15668,7 @@ async function captureStack(paths, name, options = {}) {
|
|
|
15475
15668
|
if (existsSync5(dest) && !options.force) {
|
|
15476
15669
|
throw new UserError(`Stack "${name}" already exists. Use --force to overwrite.`);
|
|
15477
15670
|
}
|
|
15478
|
-
const
|
|
15479
|
-
const agents = entriesToStackAgents(entries);
|
|
15671
|
+
const agents = await captureAgents(paths);
|
|
15480
15672
|
if (Object.keys(agents).length === 0) {
|
|
15481
15673
|
throw new UserError(
|
|
15482
15674
|
`No agent .md files with a frontmatter \`model:\` line found in ${paths.agentsDir}.`
|
|
@@ -15488,11 +15680,17 @@ async function captureStack(paths, name, options = {}) {
|
|
|
15488
15680
|
}
|
|
15489
15681
|
|
|
15490
15682
|
// src/version.ts
|
|
15491
|
-
var VERSION = "1.0
|
|
15683
|
+
var VERSION = "1.1.0";
|
|
15492
15684
|
|
|
15493
15685
|
// src/plugin.ts
|
|
15494
15686
|
async function safeToast(client, message, variant = "success") {
|
|
15495
15687
|
try {
|
|
15688
|
+
if (client.tui?.showToast) {
|
|
15689
|
+
const result = await client.tui.showToast({ body: { message, variant, duration: 15e3 } });
|
|
15690
|
+
if (result && typeof result === "object" && "error" in result && result.error)
|
|
15691
|
+
throw result.error;
|
|
15692
|
+
return;
|
|
15693
|
+
}
|
|
15496
15694
|
if (client.tui?.toast?.show) {
|
|
15497
15695
|
await client.tui.toast.show({ body: { message, variant } });
|
|
15498
15696
|
return;
|
|
@@ -15514,6 +15712,30 @@ function errOut(e) {
|
|
|
15514
15712
|
var AgentRouterPlugin = async (ctx) => {
|
|
15515
15713
|
const paths = await resolvePathsWithConfig();
|
|
15516
15714
|
const client = ctx.client;
|
|
15715
|
+
const stateBytes = () => {
|
|
15716
|
+
try {
|
|
15717
|
+
return readFileSync(paths.statePath, "utf8");
|
|
15718
|
+
} catch {
|
|
15719
|
+
return void 0;
|
|
15720
|
+
}
|
|
15721
|
+
};
|
|
15722
|
+
const initialState = stateBytes();
|
|
15723
|
+
const captured = await captureAgents(paths).catch(() => ({}));
|
|
15724
|
+
const routes = Object.fromEntries(
|
|
15725
|
+
Object.entries(captured).flatMap(([agent, entry]) => {
|
|
15726
|
+
const parsed = RoutingEntrySchema.safeParse({
|
|
15727
|
+
model: entry.model,
|
|
15728
|
+
variant: entry.variant,
|
|
15729
|
+
fallbacks: entry.fallbacks
|
|
15730
|
+
});
|
|
15731
|
+
return parsed.success && parsed.data.fallbacks?.length ? [[agent, parsed.data]] : [];
|
|
15732
|
+
})
|
|
15733
|
+
);
|
|
15734
|
+
const failover = createFailover(routes, async (message) => {
|
|
15735
|
+
await client.app?.log?.({ body: { service: "agent-router", level: "warn", message } }).catch(() => {
|
|
15736
|
+
});
|
|
15737
|
+
await safeToast(client, message, "warning");
|
|
15738
|
+
});
|
|
15517
15739
|
await client.app?.log?.({
|
|
15518
15740
|
body: {
|
|
15519
15741
|
service: "agent-router",
|
|
@@ -15528,6 +15750,11 @@ var AgentRouterPlugin = async (ctx) => {
|
|
|
15528
15750
|
}).catch(() => {
|
|
15529
15751
|
});
|
|
15530
15752
|
return {
|
|
15753
|
+
event: failover.event,
|
|
15754
|
+
"chat.message": async (input, output) => {
|
|
15755
|
+
if (stateBytes() !== initialState) failover.disable();
|
|
15756
|
+
await failover.message(input, output);
|
|
15757
|
+
},
|
|
15531
15758
|
tool: {
|
|
15532
15759
|
router_status: tool({
|
|
15533
15760
|
description: "Report the active agent-router stack, the current agent \u2192 model frontmatter mapping, and all available stacks.",
|
|
@@ -15568,6 +15795,7 @@ var AgentRouterPlugin = async (ctx) => {
|
|
|
15568
15795
|
},
|
|
15569
15796
|
async execute(args) {
|
|
15570
15797
|
try {
|
|
15798
|
+
failover.disable();
|
|
15571
15799
|
const r = await applyStack(paths, args.name, {
|
|
15572
15800
|
validate: args.validate ?? true
|
|
15573
15801
|
});
|
|
@@ -15611,12 +15839,7 @@ var AgentRouterPlugin = async (ctx) => {
|
|
|
15611
15839
|
try {
|
|
15612
15840
|
let stack;
|
|
15613
15841
|
if (args.active) {
|
|
15614
|
-
|
|
15615
|
-
stack = {
|
|
15616
|
-
agents: Object.fromEntries(
|
|
15617
|
-
Object.entries(models).map(([k, model]) => [k, { model }])
|
|
15618
|
-
)
|
|
15619
|
-
};
|
|
15842
|
+
stack = { agents: await captureAgents(paths) };
|
|
15620
15843
|
} else if (args.name) {
|
|
15621
15844
|
stack = await readStack(paths, args.name);
|
|
15622
15845
|
} else {
|
|
@@ -15640,6 +15863,7 @@ var AgentRouterPlugin = async (ctx) => {
|
|
|
15640
15863
|
},
|
|
15641
15864
|
async execute(args) {
|
|
15642
15865
|
try {
|
|
15866
|
+
failover.disable();
|
|
15643
15867
|
const r = await back(paths, args.n ?? 1);
|
|
15644
15868
|
await safeToast(
|
|
15645
15869
|
client,
|