oc-agent-router 0.1.1 → 0.1.2

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
@@ -33,11 +33,27 @@ All configured models must already be available in OpenCode. Restart OpenCode af
33
33
 
34
34
  `TYPESAFE_API_KEY` is read only from the environment of the OpenCode process. Do not put it in `opencode.json`.
35
35
 
36
+ ## Local Testing
37
+
38
+ This repository includes `opencode.jsonc`, which loads the built plugin directly from `./dist/index.js`. Before starting OpenCode from this directory, update its `models` and `fallbackModel` to model IDs enabled in your OpenCode configuration.
39
+
40
+ ```sh
41
+ npm run build
42
+ TYPESAFE_API_KEY=... opencode
43
+ ```
44
+
45
+ Ask OpenCode to delegate work with a `task` subagent. A new task will receive the model selected by Jev; resumed tasks are intentionally left unchanged. Restart OpenCode after changing the plugin source, build output, or `opencode.jsonc`.
46
+
47
+ To exercise the fallback without calling Jev, omit `TYPESAFE_API_KEY`; new tasks will use `fallbackModel`.
48
+
49
+ Routing diagnostics are appended as JSON lines to `oc-agent-router.log` in the project directory. The log records the request payload, response status and body, selected model, and failures, but never the API key.
50
+
36
51
  ## Options
37
52
 
38
53
  | Option | Default | Description |
39
54
  | --- | --- | --- |
40
55
  | `models` | Required | Non-empty allowed model list in `provider/model` form. |
56
+ | `instructions` | Built-in routing guidance | Instructions sent to Jev to guide its model choice. |
41
57
  | `fallbackModel` | First model | Used when the API key is missing, Jev errors, times out, or returns an invalid choice. Must be in `models`. |
42
58
  | `jevModel` | `jev-latest` | TypeSafe model ID. Pin a version if routing behavior must be stable. |
43
59
  | `apiKeyEnv` | `TYPESAFE_API_KEY` | Environment variable containing the TypeSafe API key. |
@@ -45,7 +61,7 @@ All configured models must already be available in OpenCode. Restart OpenCode af
45
61
 
46
62
  ## How It Works
47
63
 
48
- The plugin uses the documented `tool.execute.before` hook, calls `POST https://api.typesafe.ai/v1/systemone` with a Choice question whose only choices are your allowed models, then sets the task's `model` field. It leaves the task's original `subagent_type` and description unchanged.
64
+ The plugin creates hidden model-specific variants of each subagent, calls `POST https://api.typesafe.ai/v1/systemone` with a Choice question whose only choices are your allowed models, then routes each new task to the matching hidden variant. Each variant retains the original agent's public name, prompt, and permissions, so users continue to see names such as `general` and `explore` rather than an internal routing name.
49
65
 
50
66
  Task resumes (`task_id`) are deliberately not rerouted, so a resumed session keeps its original model. API failures never expand the configured model allowlist and use `fallbackModel` instead.
51
67
 
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ import { Plugin } from '@opencode-ai/plugin';
3
3
  interface RouterOptions {
4
4
  /** Models eligible to receive subagent tasks, in OpenCode provider/model form. */
5
5
  models: string[];
6
+ /** Routing guidance sent to Jev. */
7
+ instructions?: string;
6
8
  /** TypeSafe model ID used for routing. Defaults to jev-latest. */
7
9
  jevModel?: string;
8
10
  /** Environment variable containing the TypeSafe API key. Defaults to TYPESAFE_API_KEY. */
@@ -24,9 +26,11 @@ interface FetchResponse {
24
26
  json(): Promise<unknown>;
25
27
  }
26
28
  type Fetcher = (input: string, init: RequestInit) => Promise<FetchResponse>;
29
+ type Logger = (event: string, data?: unknown) => void;
30
+ declare function routedAgentName(agent: string, model: string): string;
27
31
  declare function parseOptions(value: unknown): Required<RouterOptions>;
28
- declare function selectModel(options: Required<RouterOptions>, args: TaskArgs, apiKey: string | undefined, fetcher?: Fetcher): Promise<string>;
32
+ declare function selectModel(options: Required<RouterOptions>, args: TaskArgs, apiKey: string | undefined, fetcher?: Fetcher, log?: Logger): Promise<string>;
29
33
 
30
34
  declare const plugin: Plugin;
31
35
 
32
- export { type RouterOptions, plugin as default, parseOptions, selectModel };
36
+ export { type RouterOptions, plugin as default, parseOptions, routedAgentName, selectModel };
package/dist/index.js CHANGED
@@ -1,4 +1,20 @@
1
+ // src/index.ts
2
+ import { appendFile } from "fs/promises";
3
+ import { join } from "path";
4
+
1
5
  // src/router.ts
6
+ function routedAgentName(agent, model) {
7
+ return `oc-agent-router-${encode(agent)}-${encode(model)}`;
8
+ }
9
+ function routedAgentConfig(name, agent, model) {
10
+ return {
11
+ ...agent,
12
+ name,
13
+ model,
14
+ mode: "subagent",
15
+ hidden: true
16
+ };
17
+ }
2
18
  function parseOptions(value) {
3
19
  if (!isRecord(value) || !Array.isArray(value.models)) {
4
20
  throw new Error("oc-agent-router requires a non-empty models array");
@@ -11,42 +27,57 @@ function parseOptions(value) {
11
27
  if (!models.includes(fallbackModel)) throw new Error("oc-agent-router fallbackModel must appear in models");
12
28
  return {
13
29
  models,
30
+ instructions: typeof value.instructions === "string" && value.instructions.trim() ? value.instructions : "Choose the configured model most suitable for completing this OpenCode subagent task. Prefer a capable model for implementation, debugging, and complex reasoning; prefer an efficient model for focused exploration or simple tasks.",
14
31
  jevModel: typeof value.jevModel === "string" ? value.jevModel : "jev-latest",
15
32
  apiKeyEnv: typeof value.apiKeyEnv === "string" ? value.apiKeyEnv : "TYPESAFE_API_KEY",
16
33
  timeoutMs: typeof value.timeoutMs === "number" && value.timeoutMs >= 100 && value.timeoutMs <= 3e4 ? value.timeoutMs : 5e3,
17
34
  fallbackModel
18
35
  };
19
36
  }
20
- async function selectModel(options, args, apiKey, fetcher = fetch) {
21
- if (!apiKey) return options.fallbackModel;
37
+ async function selectModel(options, args, apiKey, fetcher = fetch, log = () => {
38
+ }) {
39
+ if (!apiKey) {
40
+ log("routing.skipped", { reason: "missing_api_key", fallbackModel: options.fallbackModel });
41
+ return options.fallbackModel;
42
+ }
22
43
  const controller = new AbortController();
23
44
  const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
24
45
  try {
46
+ const body = {
47
+ model: options.jevModel,
48
+ state: {
49
+ task: typeof args.prompt === "string" ? args.prompt : "",
50
+ description: typeof args.description === "string" ? args.description : "",
51
+ requested_agent: typeof args.subagent_type === "string" ? args.subagent_type : ""
52
+ },
53
+ questions: {
54
+ model: {
55
+ type: "choice",
56
+ instructions: options.instructions,
57
+ criteria: Object.fromEntries(options.models.map((model2) => [model2, `Use the configured OpenCode model ${model2}.`]))
58
+ }
59
+ }
60
+ };
61
+ log("routing.request", body);
25
62
  const response = await fetcher("https://api.typesafe.ai/v1/systemone", {
26
63
  method: "POST",
27
64
  headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
28
- body: JSON.stringify({
29
- model: options.jevModel,
30
- state: {
31
- task: typeof args.prompt === "string" ? args.prompt : "",
32
- description: typeof args.description === "string" ? args.description : "",
33
- requested_agent: typeof args.subagent_type === "string" ? args.subagent_type : ""
34
- },
35
- questions: {
36
- model: {
37
- type: "choice",
38
- instructions: "Choose the configured model most suitable for completing this OpenCode subagent task. Prefer a capable model for implementation, debugging, and complex reasoning; prefer an efficient model for focused exploration or simple tasks.",
39
- criteria: Object.fromEntries(options.models.map((model) => [model, `Use the configured OpenCode model ${model}.`]))
40
- }
41
- }
42
- }),
65
+ body: JSON.stringify(body),
43
66
  signal: controller.signal
44
67
  });
68
+ log("routing.response.status", { ok: response.ok, status: response.status });
69
+ const responseBody = await response.json();
70
+ log("routing.response.body", responseBody);
45
71
  if (!response.ok) return options.fallbackModel;
46
- const body = await response.json();
47
- const choice = isRecord(body) && isRecord(body.answers) && isRecord(body.answers.model) ? body.answers.model.choice : void 0;
48
- return typeof choice === "string" && options.models.includes(choice) ? choice : options.fallbackModel;
49
- } catch {
72
+ const choice = isRecord(responseBody) && isRecord(responseBody.answers) && isRecord(responseBody.answers.model) ? responseBody.answers.model.choice : void 0;
73
+ const model = typeof choice === "string" && options.models.includes(choice) ? choice : options.fallbackModel;
74
+ log("routing.selected", { model });
75
+ return model;
76
+ } catch (error) {
77
+ log("routing.error", {
78
+ error: error instanceof Error ? { name: error.name, message: error.message } : String(error),
79
+ fallbackModel: options.fallbackModel
80
+ });
50
81
  return options.fallbackModel;
51
82
  } finally {
52
83
  clearTimeout(timeout);
@@ -56,20 +87,101 @@ function isModel(value) {
56
87
  const separator = value.indexOf("/");
57
88
  return separator > 0 && separator < value.length - 1;
58
89
  }
90
+ function encode(value) {
91
+ return Array.from(value, (character) => character.codePointAt(0).toString(36)).join("-");
92
+ }
59
93
  function isRecord(value) {
60
94
  return typeof value === "object" && value !== null;
61
95
  }
62
96
 
63
97
  // src/index.ts
64
- var plugin = async (_input, rawOptions) => {
98
+ var plugin = async (input, rawOptions) => {
65
99
  const options = parseOptions(rawOptions);
100
+ const logFile = join(input.directory, "oc-agent-router.log");
101
+ const log = (event, data) => {
102
+ const entry = JSON.stringify({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), event, data });
103
+ void appendFile(logFile, `${entry}
104
+ `).catch((error) => {
105
+ console.error("[oc-agent-router] failed to write log file", error);
106
+ });
107
+ };
108
+ const routeableAgents = /* @__PURE__ */ new Set();
109
+ const routedAgents = /* @__PURE__ */ new Map();
110
+ log("plugin.initialized", { logFile });
66
111
  return {
67
- "tool.execute.before": async (input, output) => {
68
- if (input.tool !== "task") return;
112
+ async config(config) {
113
+ config.agent ??= {};
114
+ const agents = config.agent;
115
+ const sourceAgents = {
116
+ general: agents.general ?? { mode: "subagent" },
117
+ explore: agents.explore ?? { mode: "subagent" },
118
+ ...Object.fromEntries(
119
+ Object.entries(agents).filter(
120
+ (entry) => Boolean(entry[1]) && !entry[0].startsWith("oc-agent-router-") && entry[0] !== "build" && entry[0] !== "plan" && entry[1]?.mode !== "primary" && entry[1]?.disable !== true
121
+ )
122
+ )
123
+ };
124
+ for (const [name, agent] of Object.entries(sourceAgents)) {
125
+ routeableAgents.add(name);
126
+ for (const model of options.models) {
127
+ const routeName = routedAgentName(name, model);
128
+ routedAgents.set(routeName, name);
129
+ if (agents[routeName]) continue;
130
+ agents[routeName] = routedAgentConfig(name, agent, model);
131
+ }
132
+ }
133
+ },
134
+ "tool.execute.before": async (input2, output) => {
135
+ if (input2.tool !== "task") return;
69
136
  const args = output.args;
70
137
  if (typeof args.subagent_type !== "string" || typeof args.prompt !== "string" || args.task_id) return;
71
- const model = await selectModel(options, args, process.env[options.apiKeyEnv]);
72
- args.model = model;
138
+ if (args.subagent_type.startsWith("oc-agent-router-") || !routeableAgents.has(args.subagent_type)) return;
139
+ const model = await selectModel(options, args, process.env[options.apiKeyEnv], fetch, log);
140
+ const requestedAgent = args.subagent_type;
141
+ const routedAgent = routedAgentName(requestedAgent, model);
142
+ args.subagent_type = routedAgent;
143
+ log("routing.applied", { requestedAgent, routedAgent, model });
144
+ },
145
+ event: async ({ event }) => {
146
+ if (event.type !== "message.part.updated") return;
147
+ const part = event.properties.part;
148
+ if (part.type !== "tool" || part.tool !== "task") return;
149
+ if (part.state.status !== "completed" && part.state.status !== "error") return;
150
+ const routedAgent = part.state.input.subagent_type;
151
+ if (typeof routedAgent !== "string") return;
152
+ const requestedAgent = routedAgents.get(routedAgent);
153
+ if (!requestedAgent) return;
154
+ const client = input.client._client;
155
+ if (!client) {
156
+ log("routing.display_restore_failed", { reason: "missing_internal_client", requestedAgent, routedAgent });
157
+ return;
158
+ }
159
+ const restoredPart = {
160
+ ...part,
161
+ state: {
162
+ ...part.state,
163
+ input: { ...part.state.input, subagent_type: requestedAgent }
164
+ }
165
+ };
166
+ try {
167
+ const result = await client.patch({
168
+ url: "/session/{sessionID}/message/{messageID}/part/{partID}",
169
+ path: { sessionID: part.sessionID, messageID: part.messageID, partID: part.id },
170
+ body: restoredPart,
171
+ headers: { "Content-Type": "application/json" }
172
+ });
173
+ if (result.error) {
174
+ log("routing.display_restore_failed", { error: result.error, requestedAgent, routedAgent });
175
+ return;
176
+ }
177
+ log("routing.display_restored", { requestedAgent, routedAgent });
178
+ } catch (error) {
179
+ log("routing.display_restore_failed", {
180
+ error: error instanceof Error ? { name: error.name, message: error.message } : String(error),
181
+ requestedAgent,
182
+ routedAgent
183
+ });
184
+ }
73
185
  }
74
186
  };
75
187
  };
@@ -77,5 +189,6 @@ var index_default = plugin;
77
189
  export {
78
190
  index_default as default,
79
191
  parseOptions,
192
+ routedAgentName,
80
193
  selectModel
81
194
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-agent-router",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Route OpenCode subagent tasks to configured models with Jev.",
5
5
  "type": "module",
6
6
  "license": "MIT",