Official
subagents
Agents start subagents on any of your enabled agents and models, and you can @-tag an agent, model and effort in the composer to hand part of a message to one.
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/subagents@0.1.0
Permissions in 0.1.0
Files
lib.ts6.1 KB
// The spawn_subagent schema, catalog description and composer mentions.
// Formatting keeps real model identifiers intact in the instructions.
import * as z from "zod";
import type { MentionDefinition, ModelAgent, ModelChoice, SubagentTypeInfo } from "convergence";
export type CatalogAgent = Pick<ModelAgent, "id" | "name" | "models"> & { readonly error?: string };
export type SubagentType = Pick<SubagentTypeInfo, "plugin" | "id" | "title" | "description">;
const agentDescription = "The agent id. Without it, the agent of this chat.";
export const spawnInput = z.looseObject({
prompt: z.string().describe("The whole task. The subagent does not see this conversation."),
agent: z.string().describe(agentDescription).optional(),
model: z.string().describe("A model id the agent offers. Without it, the agent's default.").optional(),
effort: z.string().describe("A reasoning effort the model offers. Without it, the model's default.").optional(),
title: z.string().describe("A few words that name the subagent's task for the user.").optional(),
});
export type SpawnInput = z.infer<typeof spawnInput>;
export const TOOL = "spawn_subagent";
// The most tags the composer is offered; a catalog with hundreds of models
// (OpenCode) would otherwise flood every plugin with them.
export const MENTION_LIMIT = 1500;
// The most models of one agent the tool's description names.
const DESCRIBED_MODELS = 40;
// A value as one piece of a tag: no whitespace.
export function tokenPart(value: string) {
return String(value).trim().replace(/\s+/g, "-");
}
// What the main agent reads when the user tags an agent/model/effort:
// `{token}` and `{part}` are filled in by the chat.
export function instruction(agentId: string, model: string, effort?: string) {
const args = [`agent ${JSON.stringify(agentId)}`, `model ${JSON.stringify(model)}`];
if (effort) args.push(`effort ${JSON.stringify(effort)}`);
return (
`The part of my message tagged {token} is for a subagent: call the ${TOOL} tool with ${args.join(", ")}, ` +
"and a prompt that asks for that part with everything the subagent needs, since it does not see this conversation. " +
"Use its answer in your reply. The part: {part}"
);
}
function mention(agent: CatalogAgent, model: ModelChoice, effort?: ModelChoice["efforts"][number]): MentionDefinition {
const token = [agent.id, model.value, effort?.value]
.filter((part): part is string => Boolean(part))
.map(tokenPart)
.join("/");
return {
id: token,
token,
label: [agent.name ?? agent.id, model.name ?? model.value, effort?.name].filter(Boolean).join(" · "),
detail: effort
? `Subagent on ${model.name ?? model.value}, ${effort.name ?? effort.value} effort`
: `Subagent on ${model.name ?? model.value}`,
icon: "bot",
keywords: [agent.id, agent.name, model.value, model.name, model.group, effort?.value, effort?.name]
.filter(Boolean)
.join(" "),
prompt: instruction(agent.id, model.value, effort?.value),
};
}
// The composer tags: every agent/model, and every agent/model/effort the
// model takes.
export function mentionsFor(catalog: readonly CatalogAgent[]) {
const out: MentionDefinition[] = [];
for (const agent of catalog) {
for (const model of agent.models) {
out.push(mention(agent, model));
for (const effort of model.efforts) out.push(mention(agent, model, effort));
if (out.length >= MENTION_LIMIT) return out.slice(0, MENTION_LIMIT);
}
}
return out;
}
function modelsLine(agent: CatalogAgent) {
if (agent.error) return `unavailable (${agent.error})`;
const models = agent.models;
if (!models.length) return "its default model only";
const named = models.slice(0, DESCRIBED_MODELS).map((model) => {
const efforts = model.efforts.map((effort) => effort.value);
return efforts.length ? `${model.value} [${efforts.join("|")}]` : model.value;
});
const more = models.length > DESCRIBED_MODELS ? `, and ${models.length - DESCRIBED_MODELS} more` : "";
return `${named.join(", ")}${more}`;
}
// The tool as the agents see it, for the current catalog and subagent
// types.
export function toolSpec(catalog: readonly CatalogAgent[], types: readonly SubagentType[]) {
const agents = catalog;
const lines = [
"Starts a subagent: one of the user's own agents, on a model it offers, does a task you give it in a separate session and returns its final answer. The user watches it work under this call.",
"Use it to delegate a self-contained piece of work, to ask another model for a second opinion, or when the user's message asks for a subagent on an agent/model/effort.",
"The subagent does not see this conversation: put everything it needs in `prompt`. It works in the same folder, with its own tools.",
];
if (agents.length) {
lines.push("Agents (`agent`), with their models (`model`) and [efforts] (`effort`):");
for (const agent of agents) lines.push(`- ${agent.id} (${agent.name ?? agent.id}): ${modelsLine(agent)}`);
}
if (types.length) {
lines.push("Subagent types (`type`, instead of agent and model):");
for (const type of types) lines.push(`- ${type.plugin}/${type.id} (${type.title}): ${type.description}`);
}
const ids = agents.filter((agent) => !agent.error).map((agent) => agent.id);
const agent = ids.length ? z.enum(ids).describe(agentDescription).optional() : spawnInput.shape.agent;
const input = spawnInput
.extend({
agent,
...(types.length
? {
type: z
.enum(types.map((type) => `${type.plugin}/${type.id}`))
.describe("A subagent type to run instead of an agent and model.")
.optional(),
}
: {}),
// The original schema leaves additional properties open and has no dialect marker.
})
.meta({ $schema: undefined, additionalProperties: undefined });
return {
name: TOOL,
title: "Spawn subagent",
description: lines.join("\n"),
input,
};
}
// A title from the prompt's first line.
export function titleOf(prompt: string) {
const line = String(prompt).trim().split("\n")[0]?.trim() ?? "";
return line.length > 60 ? `${line.slice(0, 59).trimEnd()}…` : line || "Subagent";
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.1.0latest | Oct 5, 2026 | >=2 <3 | 8.6 KB | 2 permissions | Listed |
No comments yet.