opencode-agent-variants 0.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/LICENSE +21 -0
- package/README.md +257 -0
- package/agent-variants.example.jsonc +96 -0
- package/dist/config.d.ts +211 -0
- package/dist/config.js +227 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +406 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +5 -0
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +849 -0
- package/docs/CONFIG.md +134 -0
- package/package.json +75 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { parse, stringify } from "comment-json";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
export const BUILTIN_AGENT_DESCRIPTIONS = {
|
|
8
|
+
build: "The default agent. Executes tools based on configured permissions.",
|
|
9
|
+
plan: "Plan mode. Disallows all edit tools.",
|
|
10
|
+
general: "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.",
|
|
11
|
+
explore: "Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), search code for keywords (eg. \"API endpoints\"), or answer questions about the codebase (eg. \"how do API endpoints work?\"). When calling this agent, specify the desired thoroughness level: \"quick\" for basic searches, \"medium\" for moderate exploration, or \"very thorough\" for comprehensive analysis across multiple locations and naming conventions.",
|
|
12
|
+
scout: "Docs and dependency-source specialist. Use this when you need to inspect external documentation, clone dependency repositories into the managed cache, and research library implementation details without modifying the user's workspace.",
|
|
13
|
+
};
|
|
14
|
+
const Color = z.union([
|
|
15
|
+
z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
|
16
|
+
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
|
17
|
+
]);
|
|
18
|
+
const Patch = z.object({
|
|
19
|
+
model: z.string().optional(),
|
|
20
|
+
variant: z.string().optional(),
|
|
21
|
+
temperature: z.number().finite().optional(),
|
|
22
|
+
top_p: z.number().finite().optional(),
|
|
23
|
+
prompt: z.string().optional(),
|
|
24
|
+
prompt_prepend: z.string().optional(),
|
|
25
|
+
prompt_append: z.string().optional(),
|
|
26
|
+
description: z.string().optional(),
|
|
27
|
+
description_prepend: z.string().optional(),
|
|
28
|
+
description_append: z.string().optional(),
|
|
29
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
30
|
+
color: Color.optional(),
|
|
31
|
+
disable: z.boolean().optional(),
|
|
32
|
+
});
|
|
33
|
+
const Variant = Patch.extend({
|
|
34
|
+
name: z.string().min(1).optional(),
|
|
35
|
+
});
|
|
36
|
+
export const SidecarConfig = z.object({
|
|
37
|
+
debug: z.boolean().default(false),
|
|
38
|
+
models: z.record(z.string(), z.object({
|
|
39
|
+
model: z.string().min(1),
|
|
40
|
+
label: z.string().min(1).optional(),
|
|
41
|
+
})).default({}),
|
|
42
|
+
agents: z.record(z.string(), z.object({
|
|
43
|
+
disable: z.boolean().optional(),
|
|
44
|
+
parent: Patch.default({}),
|
|
45
|
+
variants: z.record(z.string(), Variant).default({}),
|
|
46
|
+
})).default({}),
|
|
47
|
+
});
|
|
48
|
+
export function defaultConfigDir() {
|
|
49
|
+
return join(homedir(), ".config", "opencode");
|
|
50
|
+
}
|
|
51
|
+
export function defaultSidecarPath(configDir = defaultConfigDir()) {
|
|
52
|
+
return join(configDir, "agent-variants.jsonc");
|
|
53
|
+
}
|
|
54
|
+
export function debugLogPath(configDir = defaultConfigDir()) {
|
|
55
|
+
return join(configDir, "agent-variants.debug.log");
|
|
56
|
+
}
|
|
57
|
+
export function emptyConfig() {
|
|
58
|
+
return { debug: false, models: {}, agents: {} };
|
|
59
|
+
}
|
|
60
|
+
export function loadSidecar(filePath = defaultSidecarPath()) {
|
|
61
|
+
if (!existsSync(filePath))
|
|
62
|
+
return emptyConfig();
|
|
63
|
+
return SidecarConfig.parse(parse(readFileSync(filePath, "utf8")));
|
|
64
|
+
}
|
|
65
|
+
export function saveSidecar(config, filePath = defaultSidecarPath()) {
|
|
66
|
+
const parsed = SidecarConfig.parse(config);
|
|
67
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
68
|
+
if (existsSync(filePath)) {
|
|
69
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
70
|
+
writeFileSync(`${filePath}.${stamp}.bak`, readFileSync(filePath));
|
|
71
|
+
}
|
|
72
|
+
const temp = `${filePath}.tmp`;
|
|
73
|
+
writeFileSync(temp, `${stringify(parsed, null, 2)}\n`);
|
|
74
|
+
renameSync(temp, filePath);
|
|
75
|
+
}
|
|
76
|
+
export function renderTemplate(input, context) {
|
|
77
|
+
if (!input || !context)
|
|
78
|
+
return input;
|
|
79
|
+
const values = {
|
|
80
|
+
parent: context.parent,
|
|
81
|
+
alias: context.alias ?? "",
|
|
82
|
+
variant_key: context.variant_key ?? "",
|
|
83
|
+
model: context.model ?? "",
|
|
84
|
+
model_label: context.model_label ?? context.model ?? "",
|
|
85
|
+
routed_agent: context.routed_agent ?? context.parent,
|
|
86
|
+
};
|
|
87
|
+
return input.replace(/\{(parent|alias|variant_key|model|model_label|routed_agent)\}/g, (_, key) => values[key] ?? "");
|
|
88
|
+
}
|
|
89
|
+
export function applyTextPatch(base, patch, context) {
|
|
90
|
+
const text = patch.description ?? base ?? "";
|
|
91
|
+
return [patch.description_prepend, text, patch.description_append]
|
|
92
|
+
.map((item) => renderTemplate(item, context))
|
|
93
|
+
.filter((item) => item && item.length > 0)
|
|
94
|
+
.join(" ")
|
|
95
|
+
.trim();
|
|
96
|
+
}
|
|
97
|
+
export function applyPromptPatch(base, patch, context) {
|
|
98
|
+
const text = patch.prompt ?? base ?? "";
|
|
99
|
+
return [patch.prompt_prepend, text, patch.prompt_append]
|
|
100
|
+
.map((item) => renderTemplate(item, context))
|
|
101
|
+
.filter((item) => item && item.length > 0)
|
|
102
|
+
.join("\n\n")
|
|
103
|
+
.trim();
|
|
104
|
+
}
|
|
105
|
+
export function resolveModel(input, config) {
|
|
106
|
+
if (!input)
|
|
107
|
+
return;
|
|
108
|
+
return config.models[input]?.model ?? input;
|
|
109
|
+
}
|
|
110
|
+
export function modelLabel(input, config) {
|
|
111
|
+
if (!input)
|
|
112
|
+
return "the configured model";
|
|
113
|
+
return config.models[input]?.label ?? config.models[input]?.model ?? input;
|
|
114
|
+
}
|
|
115
|
+
export function splitModelRef(model) {
|
|
116
|
+
if (!model)
|
|
117
|
+
return;
|
|
118
|
+
const [providerID, ...modelParts] = model.split("/");
|
|
119
|
+
if (!providerID || modelParts.length === 0)
|
|
120
|
+
return;
|
|
121
|
+
return { providerID, modelID: modelParts.join("/") };
|
|
122
|
+
}
|
|
123
|
+
export function variantName(parent, key, variant) {
|
|
124
|
+
return variant.name?.trim() || `${parent}-${key}`;
|
|
125
|
+
}
|
|
126
|
+
export function templateContext(parent, key, variant, config) {
|
|
127
|
+
const model = resolveModel(variant.model, config);
|
|
128
|
+
const alias = key ? variantName(parent, key, variant) : parent;
|
|
129
|
+
return {
|
|
130
|
+
parent,
|
|
131
|
+
alias,
|
|
132
|
+
variant_key: key,
|
|
133
|
+
model,
|
|
134
|
+
model_label: modelLabel(variant.model, config),
|
|
135
|
+
routed_agent: parent,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function generatedVariantDescription(parent, key, variant, config) {
|
|
139
|
+
const context = templateContext(parent, key, variant, config);
|
|
140
|
+
const base = variant.description ?? `Copy of the ${parent} agent using ${modelLabel(variant.model, config)}.`;
|
|
141
|
+
return [variant.description_prepend, base, variant.description_append]
|
|
142
|
+
.map((item) => renderTemplate(item, context))
|
|
143
|
+
.filter((item) => item && item.length > 0)
|
|
144
|
+
.join(" ")
|
|
145
|
+
.trim();
|
|
146
|
+
}
|
|
147
|
+
export function modelCatalogFromProviders(providers) {
|
|
148
|
+
const catalog = { providers: new Set(), providersWithModelList: new Set(), refs: new Set() };
|
|
149
|
+
const list = Array.isArray(providers) ? providers : Object.entries((providers ?? {})).map(([id, value]) => ({ id, ...value }));
|
|
150
|
+
for (const provider of list) {
|
|
151
|
+
const providerID = typeof provider.id === "string" ? provider.id : undefined;
|
|
152
|
+
if (!providerID)
|
|
153
|
+
continue;
|
|
154
|
+
catalog.providers.add(providerID);
|
|
155
|
+
const models = provider.models;
|
|
156
|
+
if (!models || Object.keys(models).length === 0)
|
|
157
|
+
continue;
|
|
158
|
+
catalog.providersWithModelList.add(providerID);
|
|
159
|
+
for (const [key, value] of Object.entries(models)) {
|
|
160
|
+
catalog.refs.add(`${providerID}/${key}`);
|
|
161
|
+
if (typeof value.id === "string")
|
|
162
|
+
catalog.refs.add(`${providerID}/${value.id}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return catalog;
|
|
166
|
+
}
|
|
167
|
+
export function validateModel(modelInput, config, catalog) {
|
|
168
|
+
const model = resolveModel(modelInput, config);
|
|
169
|
+
if (!model)
|
|
170
|
+
return;
|
|
171
|
+
const split = splitModelRef(model);
|
|
172
|
+
if (!split)
|
|
173
|
+
return `Model "${model}" must use provider/model format.`;
|
|
174
|
+
if (!catalog.providers.has(split.providerID))
|
|
175
|
+
return `Provider "${split.providerID}" is not configured for model "${model}".`;
|
|
176
|
+
if (catalog.providersWithModelList.has(split.providerID) && !catalog.refs.has(model))
|
|
177
|
+
return `Model "${model}" was not found in provider "${split.providerID}".`;
|
|
178
|
+
}
|
|
179
|
+
export function diagnoseConfig(config, input) {
|
|
180
|
+
const diagnostics = [];
|
|
181
|
+
const catalog = modelCatalogFromProviders(input.providers);
|
|
182
|
+
const knownAgents = new Set([...Object.keys(BUILTIN_AGENT_DESCRIPTIONS), ...input.agents]);
|
|
183
|
+
const generated = new Map();
|
|
184
|
+
if (!input.pluginEntries?.some((entry) => String(Array.isArray(entry) ? entry[0] : entry).includes("agent-variants"))) {
|
|
185
|
+
diagnostics.push({ level: "warning", message: "Agent Variants plugin was not found in loaded plugin entries." });
|
|
186
|
+
}
|
|
187
|
+
diagnostics.push({ level: "info", message: `Debug mode is ${config.debug ? "enabled" : "disabled"}.` });
|
|
188
|
+
for (const [agent, entry] of Object.entries(config.agents)) {
|
|
189
|
+
if (!knownAgents.has(agent))
|
|
190
|
+
diagnostics.push({ level: "warning", agent, message: `Parent agent "${agent}" is not a known built-in or configured agent.` });
|
|
191
|
+
if (entry.disable)
|
|
192
|
+
diagnostics.push({ level: "info", agent, message: `Parent "${agent}" is disabled in sidecar config.` });
|
|
193
|
+
for (const [key, variant] of Object.entries(entry.variants)) {
|
|
194
|
+
const alias = variantName(agent, key, variant);
|
|
195
|
+
const issue = validateModel(variant.model, config, catalog);
|
|
196
|
+
if (issue)
|
|
197
|
+
diagnostics.push({ level: "warning", agent, variant: key, alias, message: `Variant "${alias}" disabled at runtime: ${issue}` });
|
|
198
|
+
if (alias === agent)
|
|
199
|
+
diagnostics.push({ level: "error", agent, variant: key, alias, message: `Variant "${alias}" uses the same name as its parent.` });
|
|
200
|
+
const existing = generated.get(alias);
|
|
201
|
+
if (existing)
|
|
202
|
+
diagnostics.push({ level: "error", agent, variant: key, alias, message: `Variant alias "${alias}" duplicates ${existing.agent}.${existing.variant}.` });
|
|
203
|
+
if (!existing)
|
|
204
|
+
generated.set(alias, { agent, variant: key });
|
|
205
|
+
if (knownAgents.has(alias) && alias !== agent)
|
|
206
|
+
diagnostics.push({ level: "error", agent, variant: key, alias, message: `Variant alias "${alias}" conflicts with an existing agent.` });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return diagnostics;
|
|
210
|
+
}
|
|
211
|
+
export function fingerprint(input) {
|
|
212
|
+
return createHash("sha256")
|
|
213
|
+
.update(input.parentSessionID)
|
|
214
|
+
.update("\0")
|
|
215
|
+
.update(input.agent)
|
|
216
|
+
.update("\0")
|
|
217
|
+
.update(input.description ?? "")
|
|
218
|
+
.update("\0")
|
|
219
|
+
.update(input.prompt)
|
|
220
|
+
.digest("hex");
|
|
221
|
+
}
|
|
222
|
+
export function hasPromptPatch(patch) {
|
|
223
|
+
return patch.prompt !== undefined || patch.prompt_prepend !== undefined || patch.prompt_append !== undefined;
|
|
224
|
+
}
|
|
225
|
+
export function hasRequestPatch(patch) {
|
|
226
|
+
return patch.temperature !== undefined || patch.top_p !== undefined || patch.options !== undefined;
|
|
227
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFileSync } from "node:fs";
|
|
3
|
+
import { applyPromptPatch, applyTextPatch, BUILTIN_AGENT_DESCRIPTIONS, defaultConfigDir, defaultSidecarPath, debugLogPath, fingerprint, generatedVariantDescription, hasPromptPatch, hasRequestPatch, loadSidecar, modelCatalogFromProviders, resolveModel, splitModelRef, templateContext, validateModel, variantName, } from "./config.js";
|
|
4
|
+
const BUILTIN_AGENTS = new Set(Object.keys(BUILTIN_AGENT_DESCRIPTIONS));
|
|
5
|
+
const ROUTE_TTL = 10 * 60 * 1000;
|
|
6
|
+
const MARKER_PREFIX = "<!-- agent-variants-route:";
|
|
7
|
+
const MARKER_SUFFIX = " -->";
|
|
8
|
+
function marker(token) {
|
|
9
|
+
return `${MARKER_PREFIX}${token}${MARKER_SUFFIX}`;
|
|
10
|
+
}
|
|
11
|
+
function attr(value) {
|
|
12
|
+
return (value ?? "").replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
13
|
+
}
|
|
14
|
+
function routeModel(route) {
|
|
15
|
+
return route.model ?? "inherit";
|
|
16
|
+
}
|
|
17
|
+
function routeSummary(route) {
|
|
18
|
+
return `${route.alias} selected; native agent=${route.parent}; effective model=${routeModel(route)}; variant=${route.variant ?? "default"}`;
|
|
19
|
+
}
|
|
20
|
+
function debugEnabled() {
|
|
21
|
+
try {
|
|
22
|
+
return loadSidecar(defaultSidecarPath()).debug;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function mergeOptions(target, source) {
|
|
29
|
+
return { ...(target ?? {}), ...(source ?? {}) };
|
|
30
|
+
}
|
|
31
|
+
function applyPatch(target, patch, config, base, context) {
|
|
32
|
+
const next = target;
|
|
33
|
+
const model = resolveModel(patch.model, config);
|
|
34
|
+
if (model)
|
|
35
|
+
next.model = model;
|
|
36
|
+
if (patch.variant !== undefined)
|
|
37
|
+
next.variant = patch.variant;
|
|
38
|
+
if (patch.temperature !== undefined)
|
|
39
|
+
next.temperature = patch.temperature;
|
|
40
|
+
if (patch.top_p !== undefined)
|
|
41
|
+
next.top_p = patch.top_p;
|
|
42
|
+
if (patch.options !== undefined)
|
|
43
|
+
next.options = mergeOptions(next.options, patch.options);
|
|
44
|
+
if (patch.color !== undefined)
|
|
45
|
+
next.color = patch.color;
|
|
46
|
+
if (patch.description !== undefined || patch.description_prepend !== undefined || patch.description_append !== undefined) {
|
|
47
|
+
next.description = applyTextPatch(next.description ?? base?.description, patch, context);
|
|
48
|
+
}
|
|
49
|
+
if (patch.prompt !== undefined || patch.prompt_prepend !== undefined || patch.prompt_append !== undefined) {
|
|
50
|
+
next.prompt = applyPromptPatch(next.prompt ?? base?.prompt, patch, context);
|
|
51
|
+
}
|
|
52
|
+
return next;
|
|
53
|
+
}
|
|
54
|
+
function applyConfigPatch(target, patch, config, base, builtin, context) {
|
|
55
|
+
const safePatch = builtin && patch.prompt === undefined ? { ...patch, prompt_prepend: undefined, prompt_append: undefined } : patch;
|
|
56
|
+
return applyPatch(target, safePatch, config, base, context);
|
|
57
|
+
}
|
|
58
|
+
function virtualPatch(alias, description, patch, config) {
|
|
59
|
+
const result = {
|
|
60
|
+
mode: "subagent",
|
|
61
|
+
description,
|
|
62
|
+
};
|
|
63
|
+
const model = resolveModel(patch.model, config);
|
|
64
|
+
if (model)
|
|
65
|
+
result.model = model;
|
|
66
|
+
if (patch.variant !== undefined)
|
|
67
|
+
result.variant = patch.variant;
|
|
68
|
+
if (patch.temperature !== undefined)
|
|
69
|
+
result.temperature = patch.temperature;
|
|
70
|
+
if (patch.top_p !== undefined)
|
|
71
|
+
result.top_p = patch.top_p;
|
|
72
|
+
if (patch.options !== undefined)
|
|
73
|
+
result.options = patch.options;
|
|
74
|
+
if (patch.color !== undefined)
|
|
75
|
+
result.color = patch.color;
|
|
76
|
+
result.prompt = `Virtual alias generated by opencode-agent-variants for ${alias}. Runtime routing should execute the parent agent instead.`;
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
function assembleAgents(cfg, sidecar) {
|
|
80
|
+
cfg.agent = cfg.agent ?? {};
|
|
81
|
+
const virtualRoutes = new Map();
|
|
82
|
+
const parentPromptPatches = new Map();
|
|
83
|
+
const parentRequestPatches = new Map();
|
|
84
|
+
const diagnostics = [];
|
|
85
|
+
const catalog = modelCatalogFromProviders(cfg.provider);
|
|
86
|
+
const originalAgents = new Set([...Object.keys(cfg.agent), ...Object.keys(BUILTIN_AGENT_DESCRIPTIONS)]);
|
|
87
|
+
const generatedAliases = new Map();
|
|
88
|
+
for (const [parent, entry] of Object.entries(sidecar.agents)) {
|
|
89
|
+
const parentConfig = cfg.agent[parent];
|
|
90
|
+
if (entry.disable) {
|
|
91
|
+
diagnostics.push({ level: "info", agent: parent, message: `Parent "${parent}" is disabled in sidecar config.` });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (parentConfig?.disable === true) {
|
|
95
|
+
diagnostics.push({ level: "info", agent: parent, message: `Parent "${parent}" is disabled in OpenCode config; variants skipped.` });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const enabledVariants = Object.entries(entry.variants).filter(([, variant]) => variant.disable !== true);
|
|
99
|
+
if (enabledVariants.length === 0)
|
|
100
|
+
continue;
|
|
101
|
+
const isBuiltin = BUILTIN_AGENTS.has(parent);
|
|
102
|
+
const base = parentConfig ?? (isBuiltin ? { description: BUILTIN_AGENT_DESCRIPTIONS[parent] } : undefined);
|
|
103
|
+
if (!base && !isBuiltin) {
|
|
104
|
+
diagnostics.push({ level: "warning", agent: parent, message: `Parent agent "${parent}" was not found; variants skipped.` });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
cfg.agent[parent] = applyConfigPatch({ ...(parentConfig ?? {}) }, entry.parent, sidecar, base, isBuiltin, templateContext(parent, undefined, {}, sidecar));
|
|
108
|
+
if (isBuiltin && hasPromptPatch(entry.parent))
|
|
109
|
+
parentPromptPatches.set(parent, entry.parent);
|
|
110
|
+
if (isBuiltin && hasRequestPatch(entry.parent))
|
|
111
|
+
parentRequestPatches.set(parent, entry.parent);
|
|
112
|
+
for (const [key, variant] of enabledVariants) {
|
|
113
|
+
const alias = variantName(parent, key, variant);
|
|
114
|
+
const modelIssue = validateModel(variant.model, sidecar, catalog);
|
|
115
|
+
if (modelIssue) {
|
|
116
|
+
diagnostics.push({ level: "warning", agent: parent, variant: key, alias, message: `Variant "${alias}" disabled at runtime: ${modelIssue}` });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (alias === parent) {
|
|
120
|
+
diagnostics.push({ level: "error", agent: parent, variant: key, alias, message: `Variant "${alias}" uses the same name as its parent and was skipped.` });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const existing = generatedAliases.get(alias);
|
|
124
|
+
if (existing) {
|
|
125
|
+
diagnostics.push({ level: "error", agent: parent, variant: key, alias, message: `Variant "${alias}" duplicates ${existing} and was skipped.` });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (originalAgents.has(alias)) {
|
|
129
|
+
diagnostics.push({ level: "error", agent: parent, variant: key, alias, message: `Variant "${alias}" conflicts with an existing agent and was skipped.` });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
generatedAliases.set(alias, `${parent}.${key}`);
|
|
133
|
+
const description = generatedVariantDescription(parent, key, variant, sidecar);
|
|
134
|
+
if (isBuiltin) {
|
|
135
|
+
cfg.agent[alias] = virtualPatch(alias, description, variant, sidecar);
|
|
136
|
+
virtualRoutes.set(alias, {
|
|
137
|
+
alias,
|
|
138
|
+
parent,
|
|
139
|
+
key,
|
|
140
|
+
patch: variant,
|
|
141
|
+
model: resolveModel(variant.model, sidecar),
|
|
142
|
+
variant: variant.variant,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const copy = applyPatch({ ...(cfg.agent[parent] ?? {}) }, variant, sidecar, cfg.agent[parent], templateContext(parent, key, variant, sidecar));
|
|
147
|
+
copy.description = description;
|
|
148
|
+
delete copy.disable;
|
|
149
|
+
cfg.agent[alias] = copy;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { virtualRoutes, parentPromptPatches, parentRequestPatches, diagnostics };
|
|
153
|
+
}
|
|
154
|
+
function textFromParts(parts) {
|
|
155
|
+
const primary = parts.filter((part) => part?.type === "text" && !part.synthetic).map((part) => part.text).join("\n\n");
|
|
156
|
+
if (primary.trim())
|
|
157
|
+
return primary;
|
|
158
|
+
return parts.filter((part) => part?.type === "text").map((part) => part.text).join("\n\n");
|
|
159
|
+
}
|
|
160
|
+
function takeMarkerRoute(parts, routes) {
|
|
161
|
+
for (const part of parts) {
|
|
162
|
+
if (part?.type !== "text" || typeof part.text !== "string")
|
|
163
|
+
continue;
|
|
164
|
+
const start = part.text.indexOf(MARKER_PREFIX);
|
|
165
|
+
if (start < 0)
|
|
166
|
+
continue;
|
|
167
|
+
const end = part.text.indexOf(MARKER_SUFFIX, start);
|
|
168
|
+
if (end < 0)
|
|
169
|
+
continue;
|
|
170
|
+
const token = part.text.slice(start + MARKER_PREFIX.length, end);
|
|
171
|
+
const route = routes.get(token);
|
|
172
|
+
part.text = `${part.text.slice(0, start)}${part.text.slice(end + MARKER_SUFFIX.length)}`.trim();
|
|
173
|
+
routes.delete(token);
|
|
174
|
+
if (route)
|
|
175
|
+
return { token, route };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function removePendingToken(list, token) {
|
|
179
|
+
const index = list.findIndex((item) => item.token === token);
|
|
180
|
+
if (index >= 0)
|
|
181
|
+
list.splice(index, 1);
|
|
182
|
+
}
|
|
183
|
+
function cleanupPending(list, routes) {
|
|
184
|
+
const cutoff = Date.now() - ROUTE_TTL;
|
|
185
|
+
while (list[0] && list[0].createdAt < cutoff) {
|
|
186
|
+
const item = list.shift();
|
|
187
|
+
if (item)
|
|
188
|
+
routes?.delete(item.token);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function takePending(list, input) {
|
|
192
|
+
const exact = list.findIndex((item) => item.parentSessionID === input.parentSessionID && item.targetAgent === input.agent && item.fingerprint === input.fingerprint);
|
|
193
|
+
if (exact >= 0)
|
|
194
|
+
return list.splice(exact, 1)[0];
|
|
195
|
+
const fallback = list.findIndex((item) => item.parentSessionID === input.parentSessionID && item.targetAgent === input.agent);
|
|
196
|
+
if (fallback >= 0)
|
|
197
|
+
return list.splice(fallback, 1)[0];
|
|
198
|
+
}
|
|
199
|
+
function getData(value) {
|
|
200
|
+
if (value && typeof value === "object" && "data" in value)
|
|
201
|
+
return value.data;
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
async function getSession(client, sessionID) {
|
|
205
|
+
return getData(await client.session.get({ path: { id: sessionID } }).catch(() => undefined));
|
|
206
|
+
}
|
|
207
|
+
async function debugToast(client, enabled, title, message) {
|
|
208
|
+
if (!enabled && !debugEnabled())
|
|
209
|
+
return;
|
|
210
|
+
try {
|
|
211
|
+
appendFileSync(debugLogPath(defaultConfigDir()), `${new Date().toISOString()} ${title}: ${message}\n`);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Debug logging should never affect routing.
|
|
215
|
+
}
|
|
216
|
+
await client.tui?.showToast?.({
|
|
217
|
+
body: {
|
|
218
|
+
title,
|
|
219
|
+
message,
|
|
220
|
+
variant: "info",
|
|
221
|
+
duration: 12000,
|
|
222
|
+
},
|
|
223
|
+
}).catch(() => undefined);
|
|
224
|
+
}
|
|
225
|
+
async function warningToast(client, diagnostic) {
|
|
226
|
+
try {
|
|
227
|
+
appendFileSync(debugLogPath(defaultConfigDir()), `${new Date().toISOString()} ${diagnostic.level.toUpperCase()}: ${diagnostic.message}\n`);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Diagnostics should never affect startup.
|
|
231
|
+
}
|
|
232
|
+
if (diagnostic.level === "info")
|
|
233
|
+
return;
|
|
234
|
+
await client.tui?.showToast?.({
|
|
235
|
+
body: {
|
|
236
|
+
title: diagnostic.level === "error" ? "Agent variant skipped" : "Agent variant disabled",
|
|
237
|
+
message: diagnostic.message,
|
|
238
|
+
variant: diagnostic.level === "error" ? "error" : "warning",
|
|
239
|
+
duration: 15000,
|
|
240
|
+
},
|
|
241
|
+
}).catch(() => undefined);
|
|
242
|
+
}
|
|
243
|
+
function applyRequestPatch(output, patch) {
|
|
244
|
+
if (patch.temperature !== undefined)
|
|
245
|
+
output.temperature = patch.temperature;
|
|
246
|
+
if (patch.top_p !== undefined)
|
|
247
|
+
output.topP = patch.top_p;
|
|
248
|
+
if (patch.options !== undefined)
|
|
249
|
+
Object.assign(output.options, patch.options);
|
|
250
|
+
}
|
|
251
|
+
function applySystemPatch(system, patch, context) {
|
|
252
|
+
if (!hasPromptPatch(patch))
|
|
253
|
+
return;
|
|
254
|
+
const current = system[0] ?? "";
|
|
255
|
+
system[0] = applyPromptPatch(current, patch, context);
|
|
256
|
+
}
|
|
257
|
+
function annotateTaskOutput(text, route) {
|
|
258
|
+
const annotated = text.replace(/^<task\b([^>]*)>/, `<task$1 agent_variant="${attr(route.alias)}" routed_agent="${attr(route.parent)}" effective_model="${attr(routeModel(route))}" model_variant="${attr(route.variant ?? "default")}">`);
|
|
259
|
+
if (annotated !== text)
|
|
260
|
+
return annotated;
|
|
261
|
+
return [`<agent_variant alias="${attr(route.alias)}" routed_agent="${attr(route.parent)}" effective_model="${attr(routeModel(route))}" model_variant="${attr(route.variant ?? "default")}" />`, text].join("\n");
|
|
262
|
+
}
|
|
263
|
+
const plugin = async (input) => {
|
|
264
|
+
const sidecar = loadSidecar(defaultSidecarPath());
|
|
265
|
+
let virtualRoutes = new Map();
|
|
266
|
+
let parentPromptPatches = new Map();
|
|
267
|
+
let parentRequestPatches = new Map();
|
|
268
|
+
const pending = [];
|
|
269
|
+
const tokenRoutes = new Map();
|
|
270
|
+
const bySession = new Map();
|
|
271
|
+
const byCall = new Map();
|
|
272
|
+
return {
|
|
273
|
+
config: async (cfg) => {
|
|
274
|
+
const assembled = assembleAgents(cfg, sidecar);
|
|
275
|
+
virtualRoutes = assembled.virtualRoutes;
|
|
276
|
+
parentPromptPatches = assembled.parentPromptPatches;
|
|
277
|
+
parentRequestPatches = assembled.parentRequestPatches;
|
|
278
|
+
for (const diagnostic of assembled.diagnostics)
|
|
279
|
+
await warningToast(input.client, diagnostic);
|
|
280
|
+
},
|
|
281
|
+
"tool.execute.before": async (hookInput, output) => {
|
|
282
|
+
if (hookInput.tool !== "task")
|
|
283
|
+
return;
|
|
284
|
+
const args = output.args;
|
|
285
|
+
if (!args?.subagent_type || !args.prompt)
|
|
286
|
+
return;
|
|
287
|
+
const route = virtualRoutes.get(args.subagent_type);
|
|
288
|
+
if (!route)
|
|
289
|
+
return;
|
|
290
|
+
cleanupPending(pending, tokenRoutes);
|
|
291
|
+
const token = randomUUID();
|
|
292
|
+
tokenRoutes.set(token, route);
|
|
293
|
+
byCall.set(hookInput.callID, route);
|
|
294
|
+
pending.push({
|
|
295
|
+
...route,
|
|
296
|
+
token,
|
|
297
|
+
parentSessionID: hookInput.sessionID,
|
|
298
|
+
targetAgent: route.parent,
|
|
299
|
+
fingerprint: fingerprint({
|
|
300
|
+
parentSessionID: hookInput.sessionID,
|
|
301
|
+
agent: route.parent,
|
|
302
|
+
prompt: args.prompt,
|
|
303
|
+
description: args.description,
|
|
304
|
+
}),
|
|
305
|
+
createdAt: Date.now(),
|
|
306
|
+
});
|
|
307
|
+
args.prompt = `${args.prompt}\n\n${marker(token)}`;
|
|
308
|
+
args.subagent_type = route.parent;
|
|
309
|
+
await debugToast(input.client, sidecar.debug, "Agent variant routed", `${routeSummary(route)}; token=${token.slice(0, 8)}`);
|
|
310
|
+
},
|
|
311
|
+
"tool.execute.after": async (hookInput, output) => {
|
|
312
|
+
if (hookInput.tool !== "task")
|
|
313
|
+
return;
|
|
314
|
+
const route = byCall.get(hookInput.callID);
|
|
315
|
+
if (!route)
|
|
316
|
+
return;
|
|
317
|
+
byCall.delete(hookInput.callID);
|
|
318
|
+
output.title = `${output.title} (@${route.alias} variant)`;
|
|
319
|
+
output.metadata = {
|
|
320
|
+
...output.metadata,
|
|
321
|
+
agentVariant: {
|
|
322
|
+
alias: route.alias,
|
|
323
|
+
routedAgent: route.parent,
|
|
324
|
+
effectiveModel: routeModel(route),
|
|
325
|
+
modelVariant: route.variant ?? "default",
|
|
326
|
+
},
|
|
327
|
+
model: splitModelRef(route.model) ?? output.metadata?.model,
|
|
328
|
+
};
|
|
329
|
+
output.output = annotateTaskOutput(output.output, route);
|
|
330
|
+
await debugToast(input.client, sidecar.debug, "Agent variant result annotated", routeSummary(route));
|
|
331
|
+
},
|
|
332
|
+
"chat.message": async (hookInput, output) => {
|
|
333
|
+
const markerRoute = takeMarkerRoute(output.parts, tokenRoutes);
|
|
334
|
+
if (markerRoute) {
|
|
335
|
+
removePendingToken(pending, markerRoute.token);
|
|
336
|
+
bySession.set(hookInput.sessionID, markerRoute.route);
|
|
337
|
+
const model = splitModelRef(markerRoute.route.model);
|
|
338
|
+
if (model) {
|
|
339
|
+
output.message.model = {
|
|
340
|
+
providerID: model.providerID,
|
|
341
|
+
modelID: model.modelID,
|
|
342
|
+
};
|
|
343
|
+
if (markerRoute.route.variant)
|
|
344
|
+
output.message.model.variant = markerRoute.route.variant;
|
|
345
|
+
}
|
|
346
|
+
else if (markerRoute.route.variant) {
|
|
347
|
+
output.message.model.variant = markerRoute.route.variant;
|
|
348
|
+
}
|
|
349
|
+
await debugToast(input.client, sidecar.debug, "Agent variant model applied", `${routeSummary(markerRoute.route)}; session=${hookInput.sessionID}; token=${markerRoute.token.slice(0, 8)}`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const session = await getSession(input.client, hookInput.sessionID);
|
|
353
|
+
const fp = fingerprint({
|
|
354
|
+
parentSessionID: session?.parentID ?? "",
|
|
355
|
+
agent: output.message.agent,
|
|
356
|
+
prompt: textFromParts(output.parts),
|
|
357
|
+
});
|
|
358
|
+
const route = takePending(pending, {
|
|
359
|
+
parentSessionID: session?.parentID,
|
|
360
|
+
agent: output.message.agent,
|
|
361
|
+
fingerprint: fp,
|
|
362
|
+
});
|
|
363
|
+
if (!route)
|
|
364
|
+
return;
|
|
365
|
+
bySession.set(hookInput.sessionID, route);
|
|
366
|
+
const model = splitModelRef(route.model);
|
|
367
|
+
if (model) {
|
|
368
|
+
output.message.model = {
|
|
369
|
+
providerID: model.providerID,
|
|
370
|
+
modelID: model.modelID,
|
|
371
|
+
};
|
|
372
|
+
if (route.variant)
|
|
373
|
+
output.message.model.variant = route.variant;
|
|
374
|
+
}
|
|
375
|
+
else if (route.variant) {
|
|
376
|
+
output.message.model.variant = route.variant;
|
|
377
|
+
}
|
|
378
|
+
await debugToast(input.client, sidecar.debug, "Agent variant model applied (fallback)", `${routeSummary(route)}; session=${hookInput.sessionID}`);
|
|
379
|
+
},
|
|
380
|
+
"chat.params": async (hookInput, output) => {
|
|
381
|
+
const routed = bySession.get(hookInput.sessionID);
|
|
382
|
+
const parent = parentRequestPatches.get(hookInput.agent);
|
|
383
|
+
if (parent)
|
|
384
|
+
applyRequestPatch(output, parent);
|
|
385
|
+
if (routed)
|
|
386
|
+
applyRequestPatch(output, routed.patch);
|
|
387
|
+
},
|
|
388
|
+
"experimental.chat.system.transform": async (hookInput, output) => {
|
|
389
|
+
if (!hookInput.sessionID)
|
|
390
|
+
return;
|
|
391
|
+
const routed = bySession.get(hookInput.sessionID);
|
|
392
|
+
if (routed) {
|
|
393
|
+
const parent = parentPromptPatches.get(routed.parent);
|
|
394
|
+
if (parent)
|
|
395
|
+
applySystemPatch(output.system, parent, templateContext(routed.parent, undefined, {}, sidecar));
|
|
396
|
+
applySystemPatch(output.system, routed.patch, templateContext(routed.parent, routed.key, routed.patch, sidecar));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const session = await getSession(input.client, hookInput.sessionID);
|
|
400
|
+
const parent = session?.agent ? parentPromptPatches.get(session.agent) : undefined;
|
|
401
|
+
if (parent && session?.agent)
|
|
402
|
+
applySystemPatch(output.system, parent, templateContext(session.agent, undefined, {}, sidecar));
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
};
|
|
406
|
+
export default plugin;
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED