@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/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: entriesToStackAgents(await readAgentEntries(paths.agentsDir)) };
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)) stackEntry[k] = v;
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 entries = await readAgentEntries(paths.agentsDir);
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.7";
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
- const models = await readAgentModels(paths.agentsDir);
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,