oc-agent-router 0.1.0 → 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
- OpenCode's `task` tool does not accept a per-call model override. 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 rewrites `subagent_type` to a hidden, model-bound copy of the requested subagent.
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
 
@@ -75,4 +91,4 @@ For example, a task that asks `general` to implement a parser sends the followin
75
91
  }
76
92
  ```
77
93
 
78
- The plugin routes the built-in `general` and `explore` agents plus subagents declared in `opencode.json` under `agent`. Markdown-only subagents are left unchanged because OpenCode's plugin hook does not expose their resolved definitions for safe cloning.
94
+ Every new task is eligible for routing, including configured and Markdown-defined subagents. Task resumes (`task_id`) retain their existing model.
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,10 @@ interface FetchResponse {
24
26
  json(): Promise<unknown>;
25
27
  }
26
28
  type Fetcher = (input: string, init: RequestInit) => Promise<FetchResponse>;
27
- declare function parseOptions(value: unknown): Required<RouterOptions>;
29
+ type Logger = (event: string, data?: unknown) => void;
28
30
  declare function routedAgentName(agent: string, model: string): string;
29
- declare function selectModel(options: Required<RouterOptions>, args: TaskArgs, apiKey: string | undefined, fetcher?: Fetcher): Promise<string>;
31
+ declare function parseOptions(value: unknown): Required<RouterOptions>;
32
+ declare function selectModel(options: Required<RouterOptions>, args: TaskArgs, apiKey: string | undefined, fetcher?: Fetcher, log?: Logger): Promise<string>;
30
33
 
31
34
  declare const plugin: Plugin;
32
35
 
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,65 +27,87 @@ 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
- function routedAgentName(agent, model) {
21
- return `oc-agent-router-${encode(agent)}-${encode(model)}`;
22
- }
23
- async function selectModel(options, args, apiKey, fetcher = fetch) {
24
- 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
+ }
25
43
  const controller = new AbortController();
26
44
  const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
27
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);
28
62
  const response = await fetcher("https://api.typesafe.ai/v1/systemone", {
29
63
  method: "POST",
30
64
  headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
31
- body: JSON.stringify({
32
- model: options.jevModel,
33
- state: {
34
- task: typeof args.prompt === "string" ? args.prompt : "",
35
- description: typeof args.description === "string" ? args.description : "",
36
- requested_agent: typeof args.subagent_type === "string" ? args.subagent_type : ""
37
- },
38
- questions: {
39
- model: {
40
- type: "choice",
41
- 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.",
42
- criteria: Object.fromEntries(options.models.map((model) => [model, `Use the configured OpenCode model ${model}.`]))
43
- }
44
- }
45
- }),
65
+ body: JSON.stringify(body),
46
66
  signal: controller.signal
47
67
  });
68
+ log("routing.response.status", { ok: response.ok, status: response.status });
69
+ const responseBody = await response.json();
70
+ log("routing.response.body", responseBody);
48
71
  if (!response.ok) return options.fallbackModel;
49
- const body = await response.json();
50
- const choice = isRecord(body) && isRecord(body.answers) && isRecord(body.answers.model) ? body.answers.model.choice : void 0;
51
- return typeof choice === "string" && options.models.includes(choice) ? choice : options.fallbackModel;
52
- } 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
+ });
53
81
  return options.fallbackModel;
54
82
  } finally {
55
83
  clearTimeout(timeout);
56
84
  }
57
85
  }
58
- function encode(value) {
59
- return Array.from(value, (character) => character.codePointAt(0).toString(36)).join("-");
60
- }
61
86
  function isModel(value) {
62
87
  const separator = value.indexOf("/");
63
88
  return separator > 0 && separator < value.length - 1;
64
89
  }
90
+ function encode(value) {
91
+ return Array.from(value, (character) => character.codePointAt(0).toString(36)).join("-");
92
+ }
65
93
  function isRecord(value) {
66
94
  return typeof value === "object" && value !== null;
67
95
  }
68
96
 
69
97
  // src/index.ts
70
- var plugin = async (_input, rawOptions) => {
98
+ var plugin = async (input, rawOptions) => {
71
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
+ };
72
108
  const routeableAgents = /* @__PURE__ */ new Set();
109
+ const routedAgents = /* @__PURE__ */ new Map();
110
+ log("plugin.initialized", { logFile });
73
111
  return {
74
112
  async config(config) {
75
113
  config.agent ??= {};
@@ -79,28 +117,71 @@ var plugin = async (_input, rawOptions) => {
79
117
  explore: agents.explore ?? { mode: "subagent" },
80
118
  ...Object.fromEntries(
81
119
  Object.entries(agents).filter(
82
- (entry) => Boolean(entry[1]) && entry[0] !== "build" && entry[0] !== "plan" && entry[1]?.mode !== "primary"
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
83
121
  )
84
122
  )
85
123
  };
86
124
  for (const [name, agent] of Object.entries(sourceAgents)) {
87
- if (name.startsWith("oc-agent-router-")) continue;
88
125
  routeableAgents.add(name);
89
126
  for (const model of options.models) {
90
127
  const routeName = routedAgentName(name, model);
128
+ routedAgents.set(routeName, name);
91
129
  if (agents[routeName]) continue;
92
- agents[routeName] = { ...agent, model, mode: "subagent", hidden: true };
130
+ agents[routeName] = routedAgentConfig(name, agent, model);
93
131
  }
94
132
  }
95
133
  },
96
- "tool.execute.before": async (input, output) => {
97
- if (input.tool !== "task") return;
134
+ "tool.execute.before": async (input2, output) => {
135
+ if (input2.tool !== "task") return;
98
136
  const args = output.args;
99
137
  if (typeof args.subagent_type !== "string" || typeof args.prompt !== "string" || args.task_id) return;
100
- if (args.subagent_type.startsWith("oc-agent-router-")) return;
101
- if (!routeableAgents.has(args.subagent_type)) return;
102
- const model = await selectModel(options, args, process.env[options.apiKeyEnv]);
103
- args.subagent_type = routedAgentName(args.subagent_type, 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
+ }
104
185
  }
105
186
  };
106
187
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-agent-router",
3
- "version": "0.1.0",
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",