diff --git a/.opencode/lib/orchestrate-v2.js b/.opencode/lib/orchestrate-v2.js new file mode 100644 index 0000000..ea6001b --- /dev/null +++ b/.opencode/lib/orchestrate-v2.js @@ -0,0 +1,505 @@ +/** + * opencode-squad plugin entry for OpenCode 2. + * + * Same responsibilities as ../plugins/orchestrate.js (the OpenCode 1 entry); + * read its header for the why. This file is only the OpenCode 2 wiring: + * + * - skills: registered via ctx.skill.transform (V2 has no config hook); + * - bootstrap + context-budget line: the `context` session hook, gated on + * the hook's own agent id (src/v2/adapt.js: applyOrchestratorTransformV2); + * - role-prompt refresh: the same hook, on the first system part; + * - [TASK OUTCOME] / [CACHE STATUS]: `execute.after` on the `subagent` tool; + * - squad_compact / squad_dump / squad_patch: ctx.tool.transform with JSON + * Schema inputs (src/v2/schemas.js); + * - rate-limit guard: the `retry` session hook vetoes the retry outright + * (V1 had to abort the session from an event), the event stream covers + * terminal errors and silent hangs. + * + * Differences from OpenCode 1, by design of the V2 API: + * - no toasts: a server plugin has no TUI handle in V2; + * - squad_compact cannot choose the summarising model: V2 compaction takes + * no model, so the session's compaction settings apply. + * + * Must not import `@opencode-ai/plugin` or zod (an OpenCode 2 host provides + * neither). Never awaits a domain read during setup: reads happen lazily in + * hooks and tools. + */ + +import { formatCompactReport } from "../../src/compact.js"; +import { buildLimitMap, buildVariantMap, estimateContextTokens } from "../../src/context.js"; +import { formatInventory, hasSquad } from "../../src/inventory.js"; +import { + evaluateRetry, + formatGuardNote, + initGuardState, + isGuardedTerminalError, + isSilentHang, + normalizeGuardConfig, +} from "../../src/rate-limit-guard.js"; +import { validateRoster } from "../../src/roster.js"; +import { + applySquad, + defaultAgentDir, + formatApplyReport, + readSquad, +} from "../../src/squad-apply.js"; +import { + formatVariantLines, + isSubagentCaller, + TOOL_DESCRIPTIONS, + validateVariants, +} from "../../src/squad-tools.js"; +import { subagentUsage, taskNotes } from "../../src/task-notes.js"; +import { isTaskResultEmpty } from "../../src/task-outcome.js"; +import { + appendToResult, + applyOrchestratorTransformV2, + applySystemPartsTransform, + lastAssistantModel, + retryToStatus, + subagentOutcome, + toV1Agents, + toV1Providers, + toV1UsageMessages, +} from "../../src/v2/adapt.js"; +import { SQUAD_COMPACT_INPUT, SQUAD_DUMP_INPUT, SQUAD_PATCH_INPUT } from "../../src/v2/schemas.js"; +import { loadSkills } from "../../src/v2/skills.js"; +import { + buildPerfLookup, + ensureGlobalModelDataFresh, + loadModelData, + loadRolePrompt, + ORCHESTRATOR_AGENT, + SKILLS_DIR, + startBenchmarkRefresh, + turnMemo, +} from "./runtime.js"; + +// How long squad_compact waits for the compaction before reporting anyway. +// Timing out is not a failure: the compaction keeps running server-side. +const COMPACT_TIMEOUT_MS = 180_000; + +// How often the silence backstop ticks (see isSilentHang). +const SILENCE_POLL_INTERVAL_MS = 15_000; + +/** @param {any} res a V2 response that is either the payload or `{ data }` */ +const unwrap = (res) => (res && typeof res === "object" && "data" in res ? res.data : res); + +/** @param {any} error V2 SessionStructuredError */ +const errorText = (error) => + [error?.type, error?.message].filter((v) => typeof v === "string" && v).join(": "); + +export default { + id: "orchestrate", + async setup(ctx) { + ensureGlobalModelDataFresh(); + startBenchmarkRefresh(); + + // Same option slot as V1: { package: "opencode-squad", options: { rate_limit_guard } }. + const guardConfig = normalizeGuardConfig(ctx.options?.rate_limit_guard); + const directory = ctx.location?.directory; + + // --- lazy, process-lifetime lookups (V1 caches the same things) --- + + /** @type {Promise | null> | undefined} */ + let agentsPromise; + const getAgents = () => { + agentsPromise ??= ctx.agent + .list() + .then((r) => toV1Agents(unwrap(r))) + .catch(() => null); + return agentsPromise; + }; + + /** @type {Promise | null> | undefined} */ + let providersPromise; + const getProviders = () => { + providersPromise ??= ctx.model + .list() + .then((r) => toV1Providers(unwrap(r))) + .catch(() => null); + return providersPromise; + }; + const getLimitMap = async () => buildLimitMap((await getProviders()) ?? []); + // null means "could not ask", reported as unknown rather than as "none". + const getVariantMap = async () => { + const providers = await getProviders(); + return providers === null ? null : buildVariantMap(providers); + }; + + let inventory; + let squadDrafted = false; + const getInventory = async () => { + if (inventory !== undefined) return inventory; + const agents = await getAgents(); + squadDrafted = hasSquad(agents ?? []); + inventory = agents + ? formatInventory(agents, buildPerfLookup(directory), await getLimitMap()) + : "(no subagents available)"; + return inventory; + }; + + const getAgentModel = async (agentName) => { + if (!agentName) return null; + return (await getAgents())?.find((a) => a.name === agentName)?.model ?? null; + }; + + /** @param {string} sessionID */ + const readContext = async (sessionID) => unwrap(await ctx.session.context({ sessionID })) ?? []; + + // --- skills --- + + const skills = loadSkills([SKILLS_DIR]); + await ctx.skill.transform((editor) => { + for (const skill of skills) { + if (!editor.get(skill.id)) editor.add(skill); + } + }); + + // --- per-request transforms --- + + await ctx.session.hook("context", async (event) => { + try { + applySystemPartsTransform(event.system, loadRolePrompt); + } catch { + // The agent keeps the body it was generated with. + } + try { + await applyOrchestratorTransformV2(event, { + orchestratorAgent: ORCHESTRATOR_AGENT, + getInventory, + getLimitMap, + getHasSquad: () => squadDrafted, + getUsageMessages: async () => toV1UsageMessages(await readContext(event.sessionID)), + turnMemo, + }); + } catch { + // Best-effort, like V1: the turn goes out without the bootstrap. + } + }); + + // --- subagent result notes --- + + await ctx.tool.hook("execute.after", async (event) => { + if (event.tool !== "subagent" || event.status !== "completed") return; + const { sessionID: taskId, output } = subagentOutcome(event.result); + if (!taskId) return; + + let usage; + let model = null; + try { + const messages = await readContext(taskId); + usage = subagentUsage(toV1UsageMessages(messages)); + model = lastAssistantModel(messages); + } catch { + usage = { lastHitMs: null, contextTokens: undefined, usageUnknown: true }; + } + + const notes = taskNotes({ + taskId, + providerID: model?.providerID, + modelID: model?.modelID, + usage, + resultEmpty: isTaskResultEmpty(output), + modelData: loadModelData(directory), + limitMap: await getLimitMap(), + now: Date.now(), + }); + if (notes.length) event.result = appendToResult(event.result, notes.join("\n\n")); + }); + + // --- tools --- + + await ctx.tool.transform((editor) => { + editor.add({ + name: "squad_compact", + description: TOOL_DESCRIPTIONS.squad_compact, + input: SQUAD_COMPACT_INPUT, + execute: async (input, context) => { + if (isSubagentCaller(context.agent)) { + return { + content: + `Refused: ${context.agent} is a subagent, and compacting a session is the ` + + "orchestrator's call. Say in your result that your context is large; sarge " + + "decides what to do about it.", + }; + } + const taskId = String(input?.task_id ?? "").trim(); + if (!taskId) return { content: "squad_compact: task_id is required." }; + + let agentName; + try { + agentName = unwrap(await ctx.session.get({ sessionID: taskId }))?.agent ?? undefined; + } catch { + // Only the report's wording needs the agent. + } + const size = async () => { + try { + return estimateContextTokens(toV1UsageMessages(await readContext(taskId)))?.used; + } catch { + return undefined; + } + }; + const before = await size(); + + let timedOut = false; + try { + await ctx.session.compact({ sessionID: taskId }); + await Promise.race([ + ctx.session.wait({ sessionID: taskId }), + new Promise((resolve) => + setTimeout(() => { + timedOut = true; + resolve(undefined); + }, COMPACT_TIMEOUT_MS).unref?.(), + ), + ]); + } catch (e) { + return { + content: `squad_compact: the compaction call failed — ${e?.message ?? String(e)}`, + }; + } + // The size estimate reads the last completed turn, which a fresh + // compaction does not have yet: report "could not be read" rather + // than a number that predates the compaction. + const after = timedOut + ? undefined + : await size().then((n) => (n === before ? undefined : n)); + + const ignored = input?.model + ? ` The requested model ${input.model} was not used: OpenCode 2 compaction takes no model choice.` + : ""; + return { + content: + formatCompactReport({ + taskId, + agent: agentName, + before, + after, + model: "OpenCode's compaction model", + why: "OpenCode 2 picks it; squad_compact cannot choose", + timedOut, + }) + ignored, + }; + }, + }); + + editor.add({ + name: "squad_dump", + description: TOOL_DESCRIPTIONS.squad_dump, + input: SQUAD_DUMP_INPUT, + execute: async (input) => { + const dir = input?.directory || defaultAgentDir(); + const { roster, conflicts } = readSquad(dir); + const count = + Object.keys(roster.grunts ?? {}).length + Object.keys(roster.drills ?? {}).length; + const note = conflicts.length + ? `\n\nnotes:\n${conflicts.map((c) => ` ${c}`).join("\n")}` + : ""; + const variantLines = formatVariantLines(await getVariantMap(), roster); + return { + content: + `Agent dir: ${dir}\n${JSON.stringify(roster, null, 2)}${note}${variantLines}\n\n` + + "To change the squad, pass this same document back to squad_patch with your " + + "edits applied — keeping every agent you were not asked to touch.", + metadata: { title: `squad: ${count} agent(s)`, dir, agents: count }, + }; + }, + }); + + editor.add({ + name: "squad_patch", + description: TOOL_DESCRIPTIONS.squad_patch, + input: SQUAD_PATCH_INPUT, + execute: async (input, context) => { + if (isSubagentCaller(context.agent)) { + return { + content: + `Refused: ${context.agent} is a subagent, and the squad roster is the ` + + "orchestrator's to change. Report what you think should change and let " + + "sarge decide.", + }; + } + const dir = input?.directory || defaultAgentDir(); + const roster = { grunts: input?.grunts ?? {}, drills: input?.drills ?? {} }; + const errors = validateRoster(roster); + errors.push(...validateVariants(roster, await getVariantMap())); + if (errors.length) { + return { content: `Roster is invalid:\n${errors.map((e) => ` - ${e}`).join("\n")}` }; + } + const result = applySquad({ roster, dir, allowRemove: input?.allow_remove === true }); + if (result.ok) { + // The inventory lists the squad; let the next turn see the change. + inventory = undefined; + agentsPromise = undefined; + } + return { + content: formatApplyReport(result), + metadata: { ok: result.ok, ...result.diff }, + }; + }, + }); + }); + + // --- rate-limit guard --- + + const guardStates = new Map(); // sessionID -> GuardState + const sessionInfo = new Map(); // sessionID -> {parentID, agent} | null + const lastActivity = new Map(); // sessionID -> epoch ms + const pollTimers = new Map(); // sessionID -> interval handle + + const getSessionInfo = async (sessionID) => { + if (sessionInfo.has(sessionID)) return sessionInfo.get(sessionID); + let info = null; + try { + const s = unwrap(await ctx.session.get({ sessionID })); + if (s?.parentID) info = { parentID: s.parentID, agent: s.agent ?? null }; + } catch { + // Treat as "not a subagent" so we never guard by mistake. + } + sessionInfo.set(sessionID, info); + return info; + }; + + const stopSilenceWatch = (sessionID) => { + const timer = pollTimers.get(sessionID); + if (timer) clearInterval(timer); + pollTimers.delete(sessionID); + }; + + // Explain to the orchestrator why its subagent failed and that it should + // reroute. Steered into the parent's running turn, so it lands right after + // the failed subagent call; `resume` starts a turn if the parent is idle. + const sendGuardNote = async (parentID, agentName, result) => { + const model = await getAgentModel(agentName); + const text = formatGuardNote({ + model: model ? `${model.providerID}/${model.modelID}` : (agentName ?? "unknown model"), + provider: model?.providerID ?? "unknown", + reason: result.reason, + waitMs: result.waitMs, + cumulativeMs: result.cumulativeMs, + until: result.until, + silentMs: result.silentMs, + errorText: result.errorText, + }); + try { + await ctx.session.synthetic({ + sessionID: parentID, + text, + description: "squad rate-limit guard", + delivery: "steer", + resume: true, + }); + } catch { + // Worst case the orchestrator sees only the subagent's own failure. + } + }; + + if (guardConfig.enabled) { + await ctx.session.hook("retry", async (event) => { + if (!event.decision?.retry) return; + const info = await getSessionInfo(event.sessionID); + if (!info) return; // top-level session — never guarded + const state = guardStates.get(event.sessionID) ?? initGuardState(); + guardStates.set(event.sessionID, state); + const now = Date.now(); + const result = evaluateRetry( + state, + retryToStatus(event, now), + guardConfig, + event.error?.status, + now, + ); + if (!result.trigger) return; + state.guarded = true; + event.decision = { retry: false }; + stopSilenceWatch(event.sessionID); + void sendGuardNote(info.parentID, info.agent, result); + }); + } + + const startSilenceWatch = (sessionID) => { + if (pollTimers.has(sessionID) || !guardConfig.enabled) return; + const timer = setInterval(async () => { + const state = guardStates.get(sessionID); + if (state?.guarded) return stopSilenceWatch(sessionID); + const last = lastActivity.get(sessionID); + if (last === undefined || !isSilentHang(last, guardConfig, Date.now())) return; + const info = await getSessionInfo(sessionID); + if (!info) return stopSilenceWatch(sessionID); + guardStates.set(sessionID, { ...(state ?? initGuardState()), guarded: true }); + stopSilenceWatch(sessionID); + try { + await ctx.session.interrupt({ sessionID, continue: false }); + } catch { + // No worse off than without the guard. + } + await sendGuardNote(info.parentID, info.agent, { + reason: "silent_hang", + silentMs: Date.now() - last, + }); + }, SILENCE_POLL_INTERVAL_MS); + timer.unref?.(); + pollTimers.set(sessionID, timer); + }; + + const handleTerminalError = async (sessionID, error) => { + if (!guardConfig.enabled) return; + const state = guardStates.get(sessionID); + if (state?.guarded) return; // already handled by the retry hook + const info = await getSessionInfo(sessionID); + if (!info) return; + const text = errorText(error); + if (!isGuardedTerminalError(text, guardConfig, error?.status)) return; + guardStates.set(sessionID, { ...(state ?? initGuardState()), guarded: true }); + await sendGuardNote(info.parentID, info.agent, { reason: "terminal_error", errorText: text }); + }; + + const forget = (sessionID) => { + guardStates.delete(sessionID); + sessionInfo.delete(sessionID); + lastActivity.delete(sessionID); + stopSilenceWatch(sessionID); + }; + + const events = new AbortController(); + if (guardConfig.enabled) { + void (async () => { + for await (const event of ctx.event.subscribe({ signal: events.signal })) { + const data = event?.data; + const sessionID = data?.sessionID; + if (typeof sessionID !== "string") continue; + // Any event naming a watched session is proof of life. + if (pollTimers.has(sessionID)) lastActivity.set(sessionID, Date.now()); + switch (event.type) { + case "session.created": + if (data.parentID) { + sessionInfo.set(sessionID, { parentID: data.parentID, agent: data.agent ?? null }); + lastActivity.set(sessionID, Date.now()); + startSilenceWatch(sessionID); + } + break; + case "session.deleted": + forget(sessionID); + break; + case "session.execution.succeeded": + case "session.execution.interrupted": + stopSilenceWatch(sessionID); + break; + case "session.execution.failed": + stopSilenceWatch(sessionID); + void handleTerminalError(sessionID, data.error); + break; + } + } + })().catch(() => { + // Stream closed on unload. + }); + } + + return () => { + events.abort(); + for (const id of [...pollTimers.keys()]) stopSilenceWatch(id); + }; + }, +}; diff --git a/.opencode/lib/runtime.js b/.opencode/lib/runtime.js new file mode 100644 index 0000000..59b0148 --- /dev/null +++ b/.opencode/lib/runtime.js @@ -0,0 +1,178 @@ +// Process-wide state and file-backed lookups shared by both plugin entries: +// orchestrate.js (OpenCode 1) and orchestrate-v2.js (OpenCode 2). Kept apart +// from either entry because orchestrate.js imports `@opencode-ai/plugin`, which +// an OpenCode 2 host does not provide. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + CHECK_INTERVAL_MS, + cacheDir, + loadSnapshot, + refreshOnce, + toastFor, +} from "../../src/benchmark-cache.js"; +import { formatBench } from "../../src/benchmarks.js"; +import { createTurnMemo } from "../../src/message-transform.js"; +import { buildModelData, formatPerf, modelsChanged, readModelData } from "../../src/model-data.js"; +import { createPromptLoader } from "../../src/prompt-inject.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = path.resolve(__dirname, "../.."); +export const SKILLS_DIR = path.join(PACKAGE_ROOT, "skills"); + +// The primary agent that acts as the orchestrator. Injection targets only this +// agent's sessions (verified via message.info.agent in the Task 0 spike). +export const ORCHESTRATOR_AGENT = "build"; + +// Holds the rendered injection block for the turn in flight, so every model +// call within one turn sends byte-identical text and the provider's prompt +// cache survives. See createTurnMemo for the measurements behind it. +export const turnMemo = createTurnMemo(); + +// Serves the CURRENT bundled role prompt to every generated grunt/drill agent, +// overriding the copy frozen into its file when the squad was drafted. Cached +// on (size, mtime) so an edit to prompts/.md goes live without a restart +// while costing one stat per request instead of a read. See src/prompt-inject.js. +export const loadRolePrompt = createPromptLoader({ + readFile: (role) => fs.readFileSync(path.join(PACKAGE_ROOT, "prompts", `${role}.md`), "utf8"), + statKey: (role) => { + const st = fs.statSync(path.join(PACKAGE_ROOT, "prompts", `${role}.md`)); + return `${st.size}:${st.mtimeMs}`; + }, +}); + +// AA benchmark snapshot (models object), read once per process: the fresher of +// the bundled copy and the one the background refresher downloaded. Fixed for +// the process lifetime on purpose — see src/benchmark-cache.js. null if neither +// is usable. +const BENCH_CACHE_DIR = cacheDir(process.env, os.homedir()); +let _benchCache; // undefined = not loaded +let _benchGenerated; // `_meta.generated` of the snapshot in use +let _bundledModelCount = 0; +function loadBenchmarks() { + if (_benchCache !== undefined) return _benchCache; + _benchCache = null; + const { snapshot, bundledCount } = loadSnapshot( + path.join(PACKAGE_ROOT, "src", "benchmarks.json"), + BENCH_CACHE_DIR, + ); + _bundledModelCount = bundledCount; + if (snapshot) { + _benchCache = snapshot.models; + _benchGenerated = snapshot._meta.generated; + } + return _benchCache; +} + +// Background benchmark refresh: first check shortly after startup, then hourly +// (refreshOnce itself enforces the once-a-day / 5-failures throttle). The first +// check is delayed so its toast lands after the TUI is up and a one-shot +// `opencode run` exits before any socket is opened. Fire-and-forget under a +// hard fetch timeout, so opencode never waits on GitHub; timers are unref'd so +// they never keep the process alive. Started once per process. +const BENCH_FIRST_CHECK_MS = 30_000; +let _benchTimer; +export function startBenchmarkRefresh(notify) { + if (_benchTimer) return; + loadBenchmarks(); // pin the in-use snapshot before the refresher can replace the cache + let busy = false; + let schemaWarned = false; // the "update the plugin" toast, once per process + const tick = async () => { + if (busy) return; + busy = true; + try { + const result = await refreshOnce({ + fetch: globalThis.fetch, + dir: BENCH_CACHE_DIR, + now: Date.now(), + minModels: Math.ceil(_bundledModelCount / 2), + }); + if (result.outcome === "schema-ahead") { + if (schemaWarned) return; + schemaWarned = true; + } + const toast = toastFor(result, _benchGenerated); + if (toast) await notify?.(toast); + } catch { + // Best-effort; the next tick tries again. + } finally { + busy = false; + } + }; + _benchTimer = setTimeout(() => { + void tick(); + _benchTimer = setInterval(tick, CHECK_INTERVAL_MS); + _benchTimer.unref?.(); + }, BENCH_FIRST_CHECK_MS); + _benchTimer.unref?.(); +} + +// Hand-editable per-squad-model snapshot, keyed by opencode provider/model id. +// Looked for beside the agent dirs opencode merges: the project's .opencode +// first, then the global config dir. null if neither exists (we then fall back +// to the raw AA benchmarks). Read once per process. +// Keyed by project directory: one OpenCode 2 service process hosts plugin +// instances for several projects, and its cwd is none of them. +const _modelDataCache = new Map(); // directory -> merged models | null +/** @param {string} [directory] project root; defaults to the process cwd (V1) */ +export function loadModelData(directory = process.cwd()) { + if (_modelDataCache.has(directory)) return _modelDataCache.get(directory); + const candidates = [ + path.join(directory, ".opencode", "model_data.json"), + path.join(os.homedir(), ".config", "opencode", "model_data.json"), + ]; + const merged = {}; + let found = false; + // Global first, then project on top, so project entries win on overlap. + for (const file of [...candidates].reverse()) { + const md = readModelData(file); + if (md) { + found = true; + Object.assign(merged, md.models); + } + } + _modelDataCache.set(directory, found ? merged : null); + return _modelDataCache.get(directory); +} + +// Per-model capability tail for the inventory: model_data entry if we have one +// (precomputed indices + hand-written note), else the raw AA benchmark lookup. +/** @param {string} [directory] see loadModelData */ +export function buildPerfLookup(directory) { + const modelData = loadModelData(directory); + const benchmarks = loadBenchmarks(); + return (modelId) => { + const entry = modelData?.[modelId]; + if (entry) return formatPerf(entry); + return benchmarks ? formatBench(modelId, benchmarks) : null; + }; +} + +// Startup auto-regen of the GLOBAL model_data.json: rescan the squad, refresh +// perf from benchmarks, MERGE (preserving hand-written `info`), and write ONLY +// when the models actually changed — so a new grunt/drill or fresh benchmarks +// land automatically without clobbering edits or churning the file every boot. +// Best-effort: any failure is swallowed so it can never break startup. No-op +// when there is no squad yet (0 models). Only the global agent dir is scanned; +// a project's .opencode/model_data.json stays fully manual (via the script's +// --dir). Runs before the inventory cache is built, so the fresh file is read. +export function ensureGlobalModelDataFresh() { + try { + const benchmarks = loadBenchmarks(); + if (!benchmarks) return; + const configDir = path.join(os.homedir(), ".config", "opencode"); + const agentDir = path.join(configDir, "agent"); + const file = path.join(configDir, "model_data.json"); + const existing = readModelData(file) || {}; + const generated = new Date().toISOString().slice(0, 10); + const snapshot = buildModelData(agentDir, benchmarks, existing, generated); + if (Object.keys(snapshot.models).length === 0) return; // no squad -> nothing + if (!modelsChanged(existing, snapshot)) return; // unchanged -> no write + fs.writeFileSync(file, `${JSON.stringify(snapshot, null, 2)}\n`); + } catch { + // Best-effort; the inventory falls back to benchmarks.json regardless. + } +} diff --git a/.opencode/plugins/orchestrate.js b/.opencode/plugins/orchestrate.js index a2fe974..2743507 100644 --- a/.opencode/plugins/orchestrate.js +++ b/.opencode/plugins/orchestrate.js @@ -29,30 +29,12 @@ */ import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { tool } from "@opencode-ai/plugin"; -import { - CHECK_INTERVAL_MS, - cacheDir, - loadSnapshot, - refreshOnce, - toastFor, -} from "../../src/benchmark-cache.js"; -import { formatBench } from "../../src/benchmarks.js"; -import { formatCacheStatus, resolveCacheTtl } from "../../src/cache-status.js"; import { formatCompactReport, pickCompactionModel, splitModelId } from "../../src/compact.js"; -import { - buildLimitMap, - buildVariantMap, - estimateContextTokens, - formatLocalDateTime, -} from "../../src/context.js"; +import { buildLimitMap, buildVariantMap, estimateContextTokens } from "../../src/context.js"; import { formatInventory, hasSquad } from "../../src/inventory.js"; -import { applyOrchestratorTransform, createTurnMemo } from "../../src/message-transform.js"; -import { buildModelData, formatPerf, modelsChanged, readModelData } from "../../src/model-data.js"; -import { applySystemPromptTransform, createPromptLoader } from "../../src/prompt-inject.js"; +import { applyOrchestratorTransform } from "../../src/message-transform.js"; +import { applySystemPromptTransform } from "../../src/prompt-inject.js"; import { evaluateRetry, formatGuardNote, @@ -68,32 +50,25 @@ import { formatApplyReport, readSquad, } from "../../src/squad-apply.js"; -import { formatTaskOutcome, isTaskResultEmpty } from "../../src/task-outcome.js"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PACKAGE_ROOT = path.resolve(__dirname, "../.."); -const SKILLS_DIR = path.join(PACKAGE_ROOT, "skills"); - -// The primary agent that acts as the orchestrator. Injection targets only this -// agent's sessions (verified via message.info.agent in the Task 0 spike). -const ORCHESTRATOR_AGENT = "build"; - -// Holds the rendered injection block for the turn in flight, so every model -// call within one turn sends byte-identical text and the provider's prompt -// cache survives. See createTurnMemo for the measurements behind it. -const _turnMemo = createTurnMemo(); - -// Serves the CURRENT bundled role prompt to every generated grunt/drill agent, -// overriding the copy frozen into its file when the squad was drafted. Cached -// on (size, mtime) so an edit to prompts/.md goes live without a restart -// while costing one stat per request instead of a read. See src/prompt-inject.js. -const _loadRolePrompt = createPromptLoader({ - readFile: (role) => fs.readFileSync(path.join(PACKAGE_ROOT, "prompts", `${role}.md`), "utf8"), - statKey: (role) => { - const st = fs.statSync(path.join(PACKAGE_ROOT, "prompts", `${role}.md`)); - return `${st.size}:${st.mtimeMs}`; - }, -}); +import { + ARG_DESCRIPTIONS, + formatVariantLines, + isSubagentCaller, + TOOL_DESCRIPTIONS, + validateVariants, +} from "../../src/squad-tools.js"; +import { subagentUsage, taskNotes } from "../../src/task-notes.js"; +import { isTaskResultEmpty } from "../../src/task-outcome.js"; +import { + buildPerfLookup, + ensureGlobalModelDataFresh, + loadModelData, + loadRolePrompt, + ORCHESTRATOR_AGENT, + SKILLS_DIR, + startBenchmarkRefresh, + turnMemo, +} from "../lib/runtime.js"; // Cache the subagent inventory string (and whether a squad has been drafted) // per process — neither changes at runtime. The bootstrap itself is assembled @@ -101,141 +76,6 @@ const _loadRolePrompt = createPromptLoader({ let _inventoryCache; // undefined = not loaded let _hasSquadCache; // undefined = not loaded -// AA benchmark snapshot (models object), read once per process: the fresher of -// the bundled copy and the one the background refresher downloaded. Fixed for -// the process lifetime on purpose — see src/benchmark-cache.js. null if neither -// is usable. -const BENCH_CACHE_DIR = cacheDir(process.env, os.homedir()); -let _benchCache; // undefined = not loaded -let _benchGenerated; // `_meta.generated` of the snapshot in use -let _bundledModelCount = 0; -function loadBenchmarks() { - if (_benchCache !== undefined) return _benchCache; - _benchCache = null; - const { snapshot, bundledCount } = loadSnapshot( - path.join(PACKAGE_ROOT, "src", "benchmarks.json"), - BENCH_CACHE_DIR, - ); - _bundledModelCount = bundledCount; - if (snapshot) { - _benchCache = snapshot.models; - _benchGenerated = snapshot._meta.generated; - } - return _benchCache; -} - -// Background benchmark refresh: first check shortly after startup, then hourly -// (refreshOnce itself enforces the once-a-day / 5-failures throttle). The first -// check is delayed so its toast lands after the TUI is up and a one-shot -// `opencode run` exits before any socket is opened. Fire-and-forget under a -// hard fetch timeout, so opencode never waits on GitHub; timers are unref'd so -// they never keep the process alive. Started once per process. -const BENCH_FIRST_CHECK_MS = 30_000; -let _benchTimer; -function startBenchmarkRefresh(client, directory) { - if (_benchTimer) return; - loadBenchmarks(); // pin the in-use snapshot before the refresher can replace the cache - let busy = false; - let schemaWarned = false; // the "update the plugin" toast, once per process - const tick = async () => { - if (busy) return; - busy = true; - try { - const result = await refreshOnce({ - fetch: globalThis.fetch, - dir: BENCH_CACHE_DIR, - now: Date.now(), - minModels: Math.ceil(_bundledModelCount / 2), - }); - if (result.outcome === "schema-ahead") { - if (schemaWarned) return; - schemaWarned = true; - } - const toast = toastFor(result, _benchGenerated); - if (toast) { - await client.tui?.showToast?.({ - query: { directory }, - body: { ...toast, duration: 10000 }, - }); - } - } catch { - // Best-effort; the next tick tries again. - } finally { - busy = false; - } - }; - _benchTimer = setTimeout(() => { - void tick(); - _benchTimer = setInterval(tick, CHECK_INTERVAL_MS); - _benchTimer.unref?.(); - }, BENCH_FIRST_CHECK_MS); - _benchTimer.unref?.(); -} - -// Hand-editable per-squad-model snapshot, keyed by opencode provider/model id. -// Looked for beside the agent dirs opencode merges: the project's .opencode -// first, then the global config dir. null if neither exists (we then fall back -// to the raw AA benchmarks). Read once per process. -let _modelDataCache; // undefined = not loaded -function loadModelData() { - if (_modelDataCache !== undefined) return _modelDataCache; - _modelDataCache = null; - const candidates = [ - path.join(process.cwd(), ".opencode", "model_data.json"), - path.join(os.homedir(), ".config", "opencode", "model_data.json"), - ]; - const merged = {}; - let found = false; - // Global first, then project on top, so project entries win on overlap. - for (const file of [...candidates].reverse()) { - const md = readModelData(file); - if (md) { - found = true; - Object.assign(merged, md.models); - } - } - if (found) _modelDataCache = merged; - return _modelDataCache; -} - -// Per-model capability tail for the inventory: model_data entry if we have one -// (precomputed indices + hand-written note), else the raw AA benchmark lookup. -function buildPerfLookup() { - const modelData = loadModelData(); - const benchmarks = loadBenchmarks(); - return (modelId) => { - const entry = modelData?.[modelId]; - if (entry) return formatPerf(entry); - return benchmarks ? formatBench(modelId, benchmarks) : null; - }; -} - -// Startup auto-regen of the GLOBAL model_data.json: rescan the squad, refresh -// perf from benchmarks, MERGE (preserving hand-written `info`), and write ONLY -// when the models actually changed — so a new grunt/drill or fresh benchmarks -// land automatically without clobbering edits or churning the file every boot. -// Best-effort: any failure is swallowed so it can never break startup. No-op -// when there is no squad yet (0 models). Only the global agent dir is scanned; -// a project's .opencode/model_data.json stays fully manual (via the script's -// --dir). Runs before the inventory cache is built, so the fresh file is read. -function ensureGlobalModelDataFresh() { - try { - const benchmarks = loadBenchmarks(); - if (!benchmarks) return; - const configDir = path.join(os.homedir(), ".config", "opencode"); - const agentDir = path.join(configDir, "agent"); - const file = path.join(configDir, "model_data.json"); - const existing = readModelData(file) || {}; - const generated = new Date().toISOString().slice(0, 10); - const snapshot = buildModelData(agentDir, benchmarks, existing, generated); - if (Object.keys(snapshot.models).length === 0) return; // no squad -> nothing - if (!modelsChanged(existing, snapshot)) return; // unchanged -> no write - fs.writeFileSync(file, `${JSON.stringify(snapshot, null, 2)}\n`); - } catch { - // Best-effort; the inventory falls back to benchmarks.json regardless. - } -} - // Configured orchestrator model, captured from config (fallback for turn 1, // before any assistant message reveals the actual model). let _orchestratorModel = null; @@ -291,7 +131,9 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // Refresh the global model_data.json once at plugin load (opencode startup), // before anything reads it. Cheap and best-effort; see the function comment. ensureGlobalModelDataFresh(); - startBenchmarkRefresh(client, directory); + startBenchmarkRefresh((toast) => + client.tui?.showToast?.({ query: { directory }, body: { ...toast, duration: 10000 } }), + ); // Rate-limit guard config comes from the plugin-tuple options // (["opencode-squad@...", { rate_limit_guard: {...} }]) — NOT a top-level @@ -393,44 +235,13 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // --- Cache-status hint (see src/cache-status.js) --- - // Two facts about a subagent session, from one messages fetch: - // lastHitMs — the most recent assistant message's request-start time, - // i.e. when this session last actually hit its provider. - // contextTokens — how big its context grew (last completed turn). - // Queried fresh rather than tracked in memory: this only needs to be right - // at the one moment a `task` call just completed, not continuously. + // Facts about a subagent session from one messages fetch (see subagentUsage + // in src/task-notes.js). Queried fresh rather than tracked in memory: this + // only needs to be right at the one moment a `task` call just completed. const getSubagentUsage = async (sessionID) => { try { const res = await client.session.messages({ path: { id: sessionID }, query: { directory } }); - const msgs = res?.data ?? []; - let lastHitMs = null; - let finish; - let outputTokens; - let reasoningTokens; - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i]?.info; - if (info?.role !== "assistant") continue; - if (lastHitMs === null && typeof info?.time?.created === "number") { - lastHitMs = info.time.created; - } - // The finish reason and token split come from the last assistant turn - // that actually COMPLETED — a session can end with a stub assistant - // record (no finish, all-zero tokens) after an abort, and reading the - // outcome off that stub would report "no finish reason" for every - // cancelled task. Deliberately a separate scan from lastHitMs, which - // wants the newest provider contact whether or not it finished. - if (finish === undefined && typeof info?.finish === "string" && info.finish.length > 0) { - finish = info.finish; - outputTokens = info?.tokens?.output; - reasoningTokens = info?.tokens?.reasoning; - } - if (lastHitMs !== null && finish !== undefined) break; - } - // Missing usage must not suppress the cache-status line — it degrades to - // the note without the size clause. - const ctx = estimateContextTokens(msgs); - - return { lastHitMs, contextTokens: ctx?.used, finish, outputTokens, reasoningTokens }; + return subagentUsage(res?.data ?? []); } catch { // Best-effort for the cache hint. `usageUnknown` matters more: without it // the missing finish reason would read downstream as "finished normally", @@ -441,84 +252,30 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { } }; - // Appends a [CACHE STATUS] line to a completed `task` call's result, so the - // orchestrator has what it needs to decide whether passing this task_id - // back in (to reuse the subagent session) still hits a warm provider - // cache, or whether the cache has gone cold and a fresh session is no - // worse. cache_ttl_seconds is a hand-editable model_data.json field (same - // pattern as `info`/`billing` — see src/model-data.js); left unset for + // Appends [TASK OUTCOME] and [CACHE STATUS] to a completed `task` call's + // result, so the orchestrator has what it needs to decide whether passing + // this task_id back in (to reuse the subagent session) still hits a warm + // provider cache. cache_ttl_seconds is a hand-editable model_data.json field + // (same pattern as `info`/`billing` — see src/model-data.js); left unset for // providers with no published TTL, reported honestly as "unknown" rather - // than guessed. + // than guessed. See src/task-notes.js. const handleTaskToolAfter = async (input, output) => { if (input?.tool !== "task") return; const meta = /** @type {any} */ (output)?.metadata; const taskId = meta?.sessionId; - const providerID = meta?.model?.providerID; - const modelID = meta?.model?.modelID; if (!taskId) return; - const { lastHitMs, contextTokens, finish, outputTokens, reasoningTokens, usageUnknown } = - await getSubagentUsage(taskId); - - // [TASK OUTCOME] first, and on its own terms: an empty or truncated result - // is a fact about THIS call the orchestrator has to act on, whereas the - // cache status is advice about a possible next one. It is appended even - // when the model is unknown or the session has no usable timestamp — - // suppressing it there would silently restore the exact blind spot it - // exists to close. See src/task-outcome.js. - const outcome = formatTaskOutcome({ + const notes = taskNotes({ taskId, - providerModelId: providerID && modelID ? `${providerID}/${modelID}` : undefined, - finish, - outputTokens, - reasoningTokens, + providerID: meta?.model?.providerID, + modelID: meta?.model?.modelID, + usage: await getSubagentUsage(taskId), resultEmpty: isTaskResultEmpty(/** @type {any} */ (output)?.output), - usageUnknown, - }); - if (outcome) output.output = `${output.output}\n\n${outcome}`; - - if (!providerID || !modelID) return; - if (lastHitMs === null) return; - - // model_data.json wins when the user has filled it in; otherwise resolve - // from the provider. Without that fallback the hint degraded to "TTL isn't - // published — judge for yourself" for every anthropic model, because the - // hand-edited field was simply never filled in. - const modelData = loadModelData(); - const configuredTtl = modelData?.[`${providerID}/${modelID}`]?.cache_ttl_seconds; - const ttl = - typeof configuredTtl === "number" - ? { seconds: configuredTtl, source: /** @type {const} */ ("published") } - : resolveCacheTtl(providerID); - - const limitMap = await getLimitMap(); - const contextLimit = limitMap?.[`${providerID}/${modelID}`] ?? limitMap?.[modelID]; - - const note = formatCacheStatus({ - taskId, - providerModelId: `${providerID}/${modelID}`, - lastHitMs, - // Absolute wall clock in the same format/zone the bootstrap stamps its - // own "now" with, so the orchestrator can recompute the gap at the next - // delegation instead of trusting a stale relative age. - lastHitAtText: - formatLocalDateTime( - new Date(lastHitMs), - Intl.DateTimeFormat().resolvedOptions().timeZone, - ) ?? undefined, - // The orchestrator's own clock is frozen at the start of its turn (see - // src/message-transform.js), so this stamp is the tighter lower bound on - // "now" when it computes a reuse gap later in the same turn. - nowAtText: - formatLocalDateTime(new Date(), Intl.DateTimeFormat().resolvedOptions().timeZone) ?? - undefined, - ttlSeconds: ttl.seconds, - ttlSource: ttl.source, - contextTokens, - contextLimit, + modelData: loadModelData(), + limitMap: await getLimitMap(), now: Date.now(), }); - output.output = `${output.output}\n\n${note}`; + for (const note of notes) output.output = `${output.output}\n\n${note}`; }; const waitForIdle = (sessionID, timeoutMs = 20_000) => @@ -733,46 +490,16 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // One agent's settings, shared by both role maps. Every field is optional: // `{}` is a perfectly good entry meaning "this model, role defaults". const agentEntry = tool.schema.object({ - variant: tool.schema - .string() - .optional() - .describe( - "Reasoning level. Must be one of THIS model's own `reasoning_options` values in " + - "models.dev (glm-5.3: low|high|max; claude-opus-5: low|medium|high|xhigh|max) — " + - "opencode silently ignores an unrecognized one, so a typo buys silence rather than " + - "an error. Omit for models that publish no reasoning options. Setting it does not " + - "suppress reasoning: it bounds a reasoner otherwise limited only by the output " + - "cap. Per AGENT, so a drill may think harder than the grunt on the same model.", - ), - description: tool.schema - .string() - .optional() - .describe( - "What this agent is for, in one line. The orchestrator reads exactly this in its " + - "subagent inventory when choosing who to dispatch, so make it say WHEN to pick " + - "this model — 'cheap, mechanical edits', 'strong analysis, slow'. Omit to keep the " + - "generic role default, which carries no routing signal at all.", - ), - notes: tool.schema - .string() - .optional() - .describe( - "Extra instructions appended to this agent's prompt only — per-model quirks and " + - "workarounds. Round-trips through squad_dump.", - ), + variant: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["agent.variant"]), + description: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["agent.description"]), + notes: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["agent.notes"]), steps: tool.schema .number() .int() .positive() .optional() - .describe("Max agentic iterations before opencode forces a text-only response."), - disable: tool.schema - .boolean() - .optional() - .describe( - "Park this agent without deleting it: it stops being dispatchable but its settings " + - "survive. Prefer this over deleting an agent you may want back.", - ), + .describe(ARG_DESCRIPTIONS["agent.steps"]), + disable: tool.schema.boolean().optional().describe(ARG_DESCRIPTIONS["agent.disable"]), }); const agentMap = tool.schema.record(tool.schema.string(), agentEntry); @@ -856,37 +583,17 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // assemble by hand. tool: { squad_compact: tool({ - description: - "Compact a subagent session BEFORE reusing it for a new, unrelated task: replaces its " + - "history with a summary, so the next dispatch re-reads a small context instead of the " + - "whole transcript. Use when you are about to send a LONG task down a session whose " + - "context is already large and mostly irrelevant to it — the size is in that session's " + - "last [CACHE STATUS] line. Not free: one full pass of the summarising model over the " + - "history, and the session's provider cache goes cold afterwards, so it pays off only " + - "when several turns follow. If the old history is worthless to the new task, a FRESH " + - "session is cheaper than compacting. opencode's own auto-compaction fires only at the " + - "context limit, which is far later than the moment this is for.", + description: TOOL_DESCRIPTIONS.squad_compact, args: { - task_id: tool.schema - .string() - .describe( - "The subagent session to compact — the task_id from its [CACHE STATUS] line.", - ), - model: tool.schema - .string() - .optional() - .describe( - "`provider/model` that writes the summary. Defaults to the cheapest model the squad " + - "has an agent for (a flat-rate one first), NOT the session's own model — " + - "summarising is mechanical and does not need the expensive model that did the work.", - ), + task_id: tool.schema.string().describe(ARG_DESCRIPTIONS["compact.task_id"]), + model: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["compact.model"]), }, async execute(args, ctx) { // Rewriting another session's history is strictly more destructive // than a roster write, so it takes the same caller guard: a drill is // read-only by contract and must not be able to edit a grunt's // transcript. - if (/^(?:grunt|drill)-/.test(ctx?.agent ?? "")) { + if (isSubagentCaller(ctx?.agent)) { return ( `Refused: ${ctx.agent} is a subagent, and compacting a session is the ` + "orchestrator's call. Say in your result that your context is large; sarge " + @@ -965,19 +672,9 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { }), squad_dump: tool({ - description: - "Read the current squad roster: every generated agent, grouped as `grunts` and " + - "`drills` and keyed by model id, with its per-agent settings. ALWAYS call this " + - "before squad_patch — the roster is a document you edit, not one you retype. " + - "Returns JSON.", + description: TOOL_DESCRIPTIONS.squad_dump, args: { - directory: tool.schema - .string() - .optional() - .describe( - "Agent directory to read. Defaults to the global ~/.config/opencode/agent. " + - "Pass a project's .opencode/agent to scope the squad to one project.", - ), + directory: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["dump.directory"]), }, async execute(args) { const dir = args.directory || defaultAgentDir(); @@ -992,27 +689,7 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // which silently under-reports: claude-haiku-4-5 publishes only a // budget_tokens option, so an effort-values reading says "none" while // opencode in fact accepts high and max. - const variantMap = await getVariantMap(); - const ids = new Set([ - ...Object.keys(roster.grunts ?? {}), - ...Object.keys(roster.drills ?? {}), - ]); - const variantLines = - variantMap === null - ? "\n\nAccepted variants: could not be read from opencode — treat as UNKNOWN and " + - "leave variants alone rather than guessing." - : ids.size === 0 - ? "" - : `\n\nVariants opencode accepts (authoritative — do not guess, and do not derive\nthese from models.dev):\n${[ - ...ids, - ] - .sort() - .map((id) => { - const v = variantMap[id]; - if (v === undefined) return ` ${id}: unknown (model not in this install)`; - return ` ${id}: ${v.length ? v.join(" | ") : "(none — leave variant unset)"}`; - }) - .join("\n")}`; + const variantLines = formatVariantLines(await getVariantMap(), roster); return { title: `squad: ${count} agent(s)`, output: @@ -1025,42 +702,21 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { }), squad_patch: tool({ - description: - "Write the squad roster: one agent per entry under `grunts` / `drills`, keyed by " + - "model id. This is the COMPLETE intended squad, not a delta — an agent absent from " + - "it is DELETED, so start from squad_dump and edit that. Deleting an agent the user " + - "did not ask you to delete is refused, not performed.", + description: TOOL_DESCRIPTIONS.squad_patch, args: { - grunts: agentMap - .optional() - .describe("Executors, keyed by model id. `{}` is a valid entry: role defaults."), - drills: agentMap - .optional() - .describe( - "Reviewers, keyed by model id. A drill's verdict is what the orchestrator acts " + - "on, so give one only to a model that can actually review — a weak reviewer " + - "rubber-stamps the work or invents faults, and both are worse than no review " + - "because they launder a bad change as an approved one. Cheap and small " + - "models belong in `grunts` alone.", - ), + grunts: agentMap.optional().describe(ARG_DESCRIPTIONS["patch.grunts"]), + drills: agentMap.optional().describe(ARG_DESCRIPTIONS["patch.drills"]), allow_remove: tool.schema .boolean() .optional() - .describe( - "Permit deleting agents present in the current squad but absent from this " + - "roster. Pass true ONLY when the user asked for that deletion — never to " + - "get past the refusal.", - ), - directory: tool.schema - .string() - .optional() - .describe("Agent directory to write. Defaults to the global ~/.config/opencode/agent."), + .describe(ARG_DESCRIPTIONS["patch.allow_remove"]), + directory: tool.schema.string().optional().describe(ARG_DESCRIPTIONS["patch.directory"]), }, async execute(args, ctx) { // A grunt or drill rewriting the squad mid-task is never intended, and // the damage outlives the session. Same reasoning as the deletion // guard: make the destructive path require the right caller. - if (/^(?:grunt|drill)-/.test(ctx?.agent ?? "")) { + if (isSubagentCaller(ctx?.agent)) { return { title: "squad_patch refused", output: @@ -1072,32 +728,7 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { const dir = args.directory || defaultAgentDir(); const roster = { grunts: args.grunts ?? {}, drills: args.drills ?? {} }; const errors = validateRoster(roster); - // A variant opencode does not recognize is DROPPED by it without any - // error, so a typo would otherwise land as a silently ineffective - // config. We can ask opencode what it accepts, so refuse instead — - // but only when the lookup worked: our inability to check must not - // block a legitimate edit. - const variantMap = await getVariantMap(); - if (variantMap) { - for (const [key, map] of Object.entries({ - grunts: roster.grunts, - drills: roster.drills, - })) { - for (const [id, entry] of Object.entries(map ?? {})) { - const want = entry?.variant; - if (!want) continue; - const accepted = variantMap[id]; - if (accepted === undefined) continue; // model not in this install - if (!accepted.includes(want)) { - errors.push( - `${key}["${id}"].variant "${want}" is not accepted by opencode for this ` + - `model — it would be ignored silently. Accepted: ` + - `${accepted.length ? accepted.join(" | ") : "(none: leave it unset)"}`, - ); - } - } - } - } + errors.push(...validateVariants(roster, await getVariantMap())); if (errors.length) { return { title: "squad_patch rejected", @@ -1127,7 +758,7 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { // starting with the grunt body), and no-ops on anything without our fence — // sarge and hand-authored agents included. "experimental.chat.system.transform": async (_input, output) => { - applySystemPromptTransform(output.system, _loadRolePrompt); + applySystemPromptTransform(output.system, loadRolePrompt); }, "experimental.chat.messages.transform": async (_input, output) => { @@ -1137,7 +768,7 @@ export const OrchestratePlugin = async ({ client, directory }, rawOptions) => { getLimitMap, getHasSquad: () => _hasSquadCache, orchestratorModel: _orchestratorModel, - turnMemo: _turnMemo, + turnMemo, }); }, }; diff --git a/README.md b/README.md index 25a2eea..1f5aff3 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,28 @@ Add one line to `~/.config/opencode/opencode.json`: That is all. On next start, OpenCode registers the skills and bootstrap automatically. There is no bundled grunt/drill agent — run the `squad-draft` skill once to generate the per-model squad the orchestrator delegates to. +### OpenCode 2 + +The same package works on OpenCode 2. Use the plural `plugins` key; plugin options (e.g. `rate_limit_guard`) go in the object form: + +```json +{ + "plugins": [ + { + "package": "opencode-squad@git+https://github.com/GFN-CIS/opencode-squad.git", + "options": { "rate_limit_guard": { "max_wait_seconds": 1800 } } + } + ] +} +``` + +The package entry (`index.js`) carries both generations: OpenCode 2 calls its `setup` (`.opencode/lib/orchestrate-v2.js`), OpenCode 1 (>= 1.18.29) its `server`, which loads the V1 entry `.opencode/plugins/orchestrate.js`. Both run the same `src/` modules. Differences on 2: + +- The delegation tool is `subagent`; the `[TASK OUTCOME]` / `[CACHE STATUS]` notes are appended to its result. +- The rate-limit guard vetoes the retry through OpenCode 2's `retry` hook instead of aborting the subagent, and steers its explanation into the orchestrator's running turn. +- No toasts: an OpenCode 2 server plugin has no TUI handle (the benchmark refresh still runs, silently). +- `squad_compact` cannot choose the summarising model — OpenCode 2 compaction takes none — and says so in its report. + --- ## What you get diff --git a/biome.json b/biome.json index 8c60054..d116506 100644 --- a/biome.json +++ b/biome.json @@ -18,7 +18,10 @@ "linter": { "enabled": true, "rules": { - "preset": "recommended" + "preset": "recommended", + "correctness": { + "noUndeclaredVariables": "error" + } } }, "javascript": { diff --git a/index.js b/index.js new file mode 100644 index 0000000..2a97190 --- /dev/null +++ b/index.js @@ -0,0 +1,15 @@ +// Package entry for both OpenCode generations (OpenCode 1 >= 1.18.29). +// +// OpenCode 2 calls `setup` (.opencode/lib/orchestrate-v2.js). OpenCode 1 +// calls `server`, which lazy-imports the V1 entry: that module imports +// `@opencode-ai/plugin`, which an OpenCode 2 host does not provide, so it must +// never load there. +import v2 from "./.opencode/lib/orchestrate-v2.js"; + +export default { + ...v2, + async server(input, options) { + const { OrchestratePlugin } = await import("./.opencode/plugins/orchestrate.js"); + return OrchestratePlugin(input, options); + }, +}; diff --git a/knip.json b/knip.json index c94810e..d756a53 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,10 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["tests/**/*.test.js", "scripts/*.mjs"], - "project": ["src/**/*.js", ".opencode/plugins/**/*.js", "scripts/**/*.mjs"] + "entry": [".opencode/plugins/orchestrate.js", "tests/**/*.test.js", "scripts/*.mjs"], + "project": [ + "src/**/*.js", + ".opencode/plugins/**/*.js", + ".opencode/lib/**/*.js", + "scripts/**/*.mjs" + ] } diff --git a/package.json b/package.json index f73c8ba..1a26bf9 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "opencode-squad", - "version": "0.1.0", + "version": "0.2.0", "description": "OpenCode plugin that turns the build agent into a PDCA orchestrator (sarge) over grunt/drill subagents", "type": "module", - "main": ".opencode/plugins/orchestrate.js", + "main": "index.js", "files": [ + "index.js", ".opencode", "src", "prompts", diff --git a/src/squad-tools.js b/src/squad-tools.js new file mode 100644 index 0000000..c58bfa8 --- /dev/null +++ b/src/squad-tools.js @@ -0,0 +1,142 @@ +// Host-independent text and decisions for the squad_* tools. +// +// The OpenCode 1 entry (.opencode/plugins/orchestrate.js) declares the tool +// arguments with zod via `tool.schema`; the OpenCode 2 entry declares them as +// JSON Schema. Both read their wording from here so the two cannot drift, and +// both use the same variant reporting/validation below. + +/** Tool descriptions, keyed by tool name. */ +export const TOOL_DESCRIPTIONS = { + squad_compact: + "Compact a subagent session BEFORE reusing it for a new, unrelated task: replaces its " + + "history with a summary, so the next dispatch re-reads a small context instead of the " + + "whole transcript. Use when you are about to send a LONG task down a session whose " + + "context is already large and mostly irrelevant to it — the size is in that session's " + + "last [CACHE STATUS] line. Not free: one full pass of the summarising model over the " + + "history, and the session's provider cache goes cold afterwards, so it pays off only " + + "when several turns follow. If the old history is worthless to the new task, a FRESH " + + "session is cheaper than compacting. opencode's own auto-compaction fires only at the " + + "context limit, which is far later than the moment this is for.", + squad_dump: + "Read the current squad roster: every generated agent, grouped as `grunts` and " + + "`drills` and keyed by model id, with its per-agent settings. ALWAYS call this " + + "before squad_patch — the roster is a document you edit, not one you retype. " + + "Returns JSON.", + squad_patch: + "Write the squad roster: one agent per entry under `grunts` / `drills`, keyed by " + + "model id. This is the COMPLETE intended squad, not a delta — an agent absent from " + + "it is DELETED, so start from squad_dump and edit that. Deleting an agent the user " + + "did not ask you to delete is refused, not performed.", +}; + +/** Argument descriptions, keyed by argument (agent-entry fields under `agent.*`). */ +export const ARG_DESCRIPTIONS = { + "agent.variant": + "Reasoning level. Must be one of THIS model's own `reasoning_options` values in " + + "models.dev (glm-5.3: low|high|max; claude-opus-5: low|medium|high|xhigh|max) — " + + "opencode silently ignores an unrecognized one, so a typo buys silence rather than " + + "an error. Omit for models that publish no reasoning options. Setting it does not " + + "suppress reasoning: it bounds a reasoner otherwise limited only by the output " + + "cap. Per AGENT, so a drill may think harder than the grunt on the same model.", + "agent.description": + "What this agent is for, in one line. The orchestrator reads exactly this in its " + + "subagent inventory when choosing who to dispatch, so make it say WHEN to pick " + + "this model — 'cheap, mechanical edits', 'strong analysis, slow'. Omit to keep the " + + "generic role default, which carries no routing signal at all.", + "agent.notes": + "Extra instructions appended to this agent's prompt only — per-model quirks and " + + "workarounds. Round-trips through squad_dump.", + "agent.steps": "Max agentic iterations before opencode forces a text-only response.", + "agent.disable": + "Park this agent without deleting it: it stops being dispatchable but its settings " + + "survive. Prefer this over deleting an agent you may want back.", + "compact.task_id": "The subagent session to compact — the task_id from its [CACHE STATUS] line.", + "compact.model": + "`provider/model` that writes the summary. Defaults to the cheapest model the squad " + + "has an agent for (a flat-rate one first), NOT the session's own model — " + + "summarising is mechanical and does not need the expensive model that did the work.", + "dump.directory": + "Agent directory to read. Defaults to the global ~/.config/opencode/agent. " + + "Pass a project's .opencode/agent to scope the squad to one project.", + "patch.grunts": "Executors, keyed by model id. `{}` is a valid entry: role defaults.", + "patch.drills": + "Reviewers, keyed by model id. A drill's verdict is what the orchestrator acts " + + "on, so give one only to a model that can actually review — a weak reviewer " + + "rubber-stamps the work or invents faults, and both are worse than no review " + + "because they launder a bad change as an approved one. Cheap and small " + + "models belong in `grunts` alone.", + "patch.allow_remove": + "Permit deleting agents present in the current squad but absent from this " + + "roster. Pass true ONLY when the user asked for that deletion — never to " + + "get past the refusal.", + "patch.directory": "Agent directory to write. Defaults to the global ~/.config/opencode/agent.", +}; + +/** + * A grunt or drill calling a roster-changing or history-rewriting tool. Those + * are the orchestrator's calls: a drill is read-only by contract, and a grunt + * rewriting the squad mid-task outlives the session that did it. + * + * @param {unknown} agent the calling agent's id + */ +export function isSubagentCaller(agent) { + return /^(?:grunt|drill)-/.test(typeof agent === "string" ? agent : ""); +} + +/** + * The "variants opencode accepts" tail of squad_dump's output. + * + * @param {Record | null} variantMap null when it could not be read + * @param {{grunts?: Record, drills?: Record}} roster + * @returns {string} + */ +export function formatVariantLines(variantMap, roster) { + const ids = new Set([...Object.keys(roster.grunts ?? {}), ...Object.keys(roster.drills ?? {})]); + if (variantMap === null) { + return ( + "\n\nAccepted variants: could not be read from opencode — treat as UNKNOWN and " + + "leave variants alone rather than guessing." + ); + } + if (ids.size === 0) return ""; + const lines = [...ids].sort().map((id) => { + const v = variantMap[id]; + if (v === undefined) return ` ${id}: unknown (model not in this install)`; + return ` ${id}: ${v.length ? v.join(" | ") : "(none — leave variant unset)"}`; + }); + return ( + "\n\nVariants opencode accepts (authoritative — do not guess, and do not derive\n" + + `these from models.dev):\n${lines.join("\n")}` + ); +} + +/** + * Variant errors for squad_patch. A variant opencode does not recognize is + * DROPPED by it without any error, so a typo would otherwise land as a silently + * ineffective config. Only checked when the lookup worked: our inability to + * check must not block a legitimate edit. + * + * @param {{grunts?: Record, drills?: Record}} roster + * @param {Record | null | undefined} variantMap + * @returns {string[]} + */ +export function validateVariants(roster, variantMap) { + if (!variantMap) return []; + const errors = []; + for (const [key, map] of Object.entries({ grunts: roster.grunts, drills: roster.drills })) { + for (const [id, entry] of Object.entries(map ?? {})) { + const want = entry?.variant; + if (!want) continue; + const accepted = variantMap[id]; + if (accepted === undefined) continue; // model not in this install + if (!accepted.includes(want)) { + errors.push( + `${key}["${id}"].variant "${want}" is not accepted by opencode for this ` + + `model — it would be ignored silently. Accepted: ` + + `${accepted.length ? accepted.join(" | ") : "(none: leave it unset)"}`, + ); + } + } + } + return errors; +} diff --git a/src/task-notes.js b/src/task-notes.js new file mode 100644 index 0000000..445c2f4 --- /dev/null +++ b/src/task-notes.js @@ -0,0 +1,129 @@ +// The [TASK OUTCOME] and [CACHE STATUS] notes appended to a finished subagent +// call, as pure functions over already-fetched data. Shared by the OpenCode 1 +// entry (the `task` tool) and the OpenCode 2 entry (the `subagent` tool); each +// entry only fetches the subagent's messages and converts them to V1 `{info}` +// items. + +import { formatCacheStatus, resolveCacheTtl } from "./cache-status.js"; +import { estimateContextTokens, formatLocalDateTime } from "./context.js"; +import { formatTaskOutcome } from "./task-outcome.js"; + +/** + * Facts about a subagent session from its messages: + * lastHitMs — the newest assistant message's request-start time, i.e. + * when this session last actually hit its provider; + * finish/…Tokens — from the newest assistant turn that actually COMPLETED. A + * session can end with a stub assistant record (no finish, + * all-zero tokens) after an abort, and reading the outcome off + * that stub would report "no finish reason" for every + * cancelled task. Deliberately a separate scan from + * lastHitMs, which wants the newest provider contact whether + * or not it finished; + * contextTokens — how big its context grew (last completed turn). Missing + * usage must not suppress the cache-status line — it degrades + * to the note without the size clause. + * + * @param {Array<{info?: any}>} messages V1 `{info}` items + */ +export function subagentUsage(messages) { + let lastHitMs = null; + let finish; + let outputTokens; + let reasoningTokens; + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i]?.info; + if (info?.role !== "assistant") continue; + if (lastHitMs === null && typeof info?.time?.created === "number") { + lastHitMs = info.time.created; + } + if (finish === undefined && typeof info?.finish === "string" && info.finish.length > 0) { + finish = info.finish; + outputTokens = info?.tokens?.output; + reasoningTokens = info?.tokens?.reasoning; + } + if (lastHitMs !== null && finish !== undefined) break; + } + const ctx = estimateContextTokens(messages); + return { lastHitMs, contextTokens: ctx?.used, finish, outputTokens, reasoningTokens }; +} + +/** + * The notes to append, in order: [TASK OUTCOME] first (a fact about THIS call + * the orchestrator has to act on — appended even when the model or the + * timestamp is unknown, since suppressing it there would restore the blind spot + * it exists to close), then [CACHE STATUS] (advice about a possible next call, + * only when the model and a usable timestamp are known). + * + * @param {object} input + * @param {string} input.taskId + * @param {string} [input.providerID] + * @param {string} [input.modelID] + * @param {ReturnType & {usageUnknown?: boolean}} input.usage + * @param {boolean} input.resultEmpty + * @param {Record | null} input.modelData model_data.json models + * @param {Record | undefined} input.limitMap + * @param {number} input.now + * @param {string} [input.timeZone] + * @returns {string[]} + */ +export function taskNotes({ + taskId, + providerID, + modelID, + usage, + resultEmpty, + modelData, + limitMap, + now, + timeZone, +}) { + const notes = []; + const outcome = formatTaskOutcome({ + taskId, + providerModelId: providerID && modelID ? `${providerID}/${modelID}` : undefined, + finish: usage.finish, + outputTokens: usage.outputTokens, + reasoningTokens: usage.reasoningTokens, + resultEmpty, + usageUnknown: usage.usageUnknown, + }); + if (outcome) notes.push(outcome); + + if (!providerID || !modelID || usage.lastHitMs === null || usage.lastHitMs === undefined) { + return notes; + } + + // model_data.json wins when the user has filled it in; otherwise resolve + // from the provider. Without that fallback the hint degraded to "TTL isn't + // published — judge for yourself" for every anthropic model, because the + // hand-edited field was simply never filled in. + const configuredTtl = modelData?.[`${providerID}/${modelID}`]?.cache_ttl_seconds; + const ttl = + typeof configuredTtl === "number" + ? { seconds: configuredTtl, source: /** @type {const} */ ("published") } + : resolveCacheTtl(providerID); + const contextLimit = limitMap?.[`${providerID}/${modelID}`] ?? limitMap?.[modelID]; + const zone = timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + + notes.push( + formatCacheStatus({ + taskId, + providerModelId: `${providerID}/${modelID}`, + lastHitMs: usage.lastHitMs, + // Absolute wall clock in the same format/zone the bootstrap stamps its + // own "now" with, so the orchestrator can recompute the gap at the next + // delegation instead of trusting a stale relative age. + lastHitAtText: formatLocalDateTime(new Date(usage.lastHitMs), zone) ?? undefined, + // The orchestrator's own clock is frozen at the start of its turn (see + // src/message-transform.js), so this stamp is the tighter lower bound on + // "now" when it computes a reuse gap later in the same turn. + nowAtText: formatLocalDateTime(new Date(now), zone) ?? undefined, + ttlSeconds: ttl.seconds, + ttlSource: ttl.source, + contextTokens: usage.contextTokens, + contextLimit, + now, + }), + ); + return notes; +} diff --git a/src/v2/adapt.js b/src/v2/adapt.js new file mode 100644 index 0000000..8d904db --- /dev/null +++ b/src/v2/adapt.js @@ -0,0 +1,258 @@ +// OpenCode 2 payloads -> the shapes the pure squad modules were written for. +// +// Everything in src/ above this folder reads OpenCode 1 shapes: agents named by +// `name` with a `{providerID, modelID}` model, providers with keyed `models`, +// messages as `{info, parts}`, the system prompt as a string array. OpenCode 2 +// changed each of them. Converting here, at the edge, keeps the decision logic +// (and its tests) shared by both entries. Shapes were captured off OpenCode +// 2.1.3 — see tests/v2-adapt.test.js for the fixtures. + +import { BOOTSTRAP_MARKER, buildBootstrap } from "../bootstrap.js"; +import { + DEFAULT_LIMIT, + estimateContextTokens, + formatContextLine, + formatLocalDateTime, +} from "../context.js"; +import { applySystemPromptTransform } from "../prompt-inject.js"; + +/** + * V2 Agent.Info[] -> V1 agent list. V2's `name` is a display name ("Build"); + * the id is what V1 called the name and what everything here matches on + * (`grunt-…`/`drill-…`). + * + * @param {readonly any[]} agents + * @returns {Array<{name: string, mode: string, description?: string, model?: {providerID: string, modelID: string}}>} + */ +export function toV1Agents(agents) { + if (!Array.isArray(agents)) return []; + return agents + .filter((a) => a && typeof a.id === "string") + .map((a) => ({ + name: a.id, + mode: a.mode, + ...(a.description ? { description: a.description } : {}), + ...(a.model?.providerID && a.model?.id + ? { model: { providerID: a.model.providerID, modelID: a.model.id } } + : {}), + })); +} + +/** + * V2 Model.Info[] (ctx.model.list) -> V1 provider list as buildLimitMap and + * buildVariantMap read it: `[{id, models: {[modelID]: {limit, variants}}}]`. + * V2 variants are `[{id, settings}]`; V1's keyed-object form is kept so + * buildVariantMap needs no change. + * + * @param {readonly any[]} models + */ +export function toV1Providers(models) { + /** @type {Map}>} */ + const byProvider = new Map(); + for (const m of Array.isArray(models) ? models : []) { + if (!m?.providerID || !m?.id) continue; + const provider = byProvider.get(m.providerID) ?? { id: m.providerID, models: {} }; + provider.models[m.id] = { + ...(m.limit ? { limit: m.limit } : {}), + variants: Object.fromEntries( + (Array.isArray(m.variants) ? m.variants : []) + .filter((v) => typeof v?.id === "string") + .map((v) => [v.id, v.settings ?? {}]), + ), + }; + byProvider.set(m.providerID, provider); + } + return [...byProvider.values()]; +} + +/** + * V2 SessionMessageInfo[] (ctx.session.context) -> V1 `{info}` items carrying + * what estimateContextTokens and the task-outcome scan read off assistant + * messages: role, time, finish, tokens, model. + * + * @param {readonly any[]} messages + */ +export function toV1UsageMessages(messages) { + if (!Array.isArray(messages)) return []; + return messages + .filter((m) => m?.type === "assistant" || m?.type === "user") + .map((m) => ({ + info: { + role: m.type, + time: m.time, + ...(m.type === "assistant" + ? { + finish: m.finish, + tokens: m.tokens, + modelID: m.model?.id, + providerID: m.model?.providerID, + } + : {}), + }, + })); +} + +/** + * The subagent's model, read off its newest assistant message (V2 keeps it + * nowhere else a plugin can reach). + * + * @param {readonly any[]} messages V2 SessionMessageInfo[] + * @returns {{providerID: string, modelID: string} | null} + */ +export function lastAssistantModel(messages) { + if (!Array.isArray(messages)) return null; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m?.type === "assistant" && m.model?.providerID && m.model?.id) { + return { providerID: m.model.providerID, modelID: m.model.id }; + } + } + return null; +} + +/** + * Role-prompt refresh on V2's `SystemPart[]`. The agent's own prompt is the + * text of the first part (verified live for a subagent session), so the V1 + * transform runs on the first text part that carries a fence. + * + * @param {Array<{type?: string, text?: string}>} system + * @param {(role: string) => string | null} loadPrompt + */ +export function applySystemPartsTransform(system, loadPrompt) { + if (!Array.isArray(system)) return { applied: false }; + const part = system.find( + (p) => p?.type === "text" && typeof p.text === "string" && p.text.includes("squad:prompt"), + ); + if (!part) return { applied: false }; + const holder = [part.text]; + const result = applySystemPromptTransform(holder, loadPrompt); + if (result.changed) part.text = holder[0]; + return result; +} + +/** + * A copy of a V2 tool result with `text` appended as its own paragraph. + * @param {any} result + * @param {string} text + */ +export function appendToResult(result, text) { + const content = result?.content; + if (Array.isArray(content)) { + return { ...result, content: [...content, { type: "text", text }] }; + } + const base = typeof content === "string" ? content : ""; + return { ...result, content: base ? `${base}\n\n${text}` : text }; +} + +/** + * What the V2 `subagent` tool returned, in the terms the task-outcome and + * cache-status code use: the child session id and the subagent's final text. + * + * @param {any} result + * @returns {{sessionID?: string, output: string}} + */ +export function subagentOutcome(result) { + const sessionID = result?.metadata?.sessionID ?? result?.output?.sessionID; + const text = result?.output?.output; + return { sessionID, output: typeof text === "string" ? text : "" }; +} + +/** + * V2 `retry` hook event -> the V1 `session.status` retry object evaluateRetry + * reads. `next` is the absolute wake-up time OpenCode is proposing. + * + * @param {{attempt: number, error?: {message?: string}, decision: {retry: boolean, delay?: number}}} event + * @param {number} now + */ +export function retryToStatus(event, now) { + const delay = event?.decision?.retry ? Number(event.decision.delay) || 0 : 0; + return { + type: "retry", + attempt: event?.attempt, + message: event?.error?.message ?? "", + next: now + Math.max(0, delay), + }; +} + +/** + * The message this turn's injection lands on: the latest user message with + * text content. V2 hands the agent id to the hook directly and runs it only for + * the agent loop (titles and compactions have hooks of their own), so neither + * V1 heuristic — agent tags on messages, internal-generation prompts — is + * needed here. + * + * @param {Array<{id?: string, role?: string, content?: any}>} messages + */ +export function findV2InjectionTarget(messages) { + if (!Array.isArray(messages)) return null; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m?.role !== "user" || !Array.isArray(m.content)) continue; + if (m.content.some((c) => c?.type === "text" && typeof c.text === "string")) return m; + } + return null; +} + +/** + * V2 counterpart of applyOrchestratorTransform (src/message-transform.js): the + * same bootstrap + context-budget line, built once per turn and replayed + * byte-identically (see createTurnMemo for why that matters for cost). + * + * @param {{agent: string, model?: {providerID?: string, id?: string}, messages: any[]}} event + * the `context` hook event (mutated in place) + * @param {object} opts + * @param {string} opts.orchestratorAgent + * @param {() => Promise} opts.getInventory + * @param {() => Promise>} opts.getLimitMap + * @param {() => boolean} opts.getHasSquad called after getInventory resolves + * @param {() => Promise} opts.getUsageMessages V1 `{info}` items for the + * session (toV1UsageMessages of ctx.session.context); V2 request messages + * carry no token usage + * @param {ReturnType} [opts.turnMemo] + */ +export async function applyOrchestratorTransformV2(event, opts) { + if (event?.agent !== opts.orchestratorAgent) return; + const target = findV2InjectionTarget(event.messages); + if (!target) return; + if ( + target.content.some( + (c) => c?.type === "text" && typeof c.text === "string" && c.text.includes(BOOTSTRAP_MARKER), + ) + ) { + return; + } + + const turnId = typeof target.id === "string" ? target.id : null; + let block = turnId && opts.turnMemo ? opts.turnMemo.get(turnId) : null; + if (!block) { + const inventory = await opts.getInventory(); + const nowText = formatLocalDateTime( + new Date(), + Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + const modelText = + event.model?.providerID && event.model?.id + ? `${event.model.providerID}/${event.model.id}` + : null; + const bootstrap = buildBootstrap(inventory, { + nowText, + modelText, + hasSquad: opts.getHasSquad(), + }); + + let contextLine = null; + const usage = estimateContextTokens(await opts.getUsageMessages()); + if (usage) { + const limits = await opts.getLimitMap(); + const limit = + limits[`${usage.providerID}/${usage.modelID}`] ?? limits[usage.modelID] ?? DEFAULT_LIMIT; + contextLine = formatContextLine(usage.used, limit); + } + + block = { bootstrap, contextLine }; + if (turnId && opts.turnMemo) opts.turnMemo.set(turnId, block); + } + + target.content.unshift({ type: "text", text: block.bootstrap }); + if (block.contextLine) target.content.push({ type: "text", text: block.contextLine }); +} diff --git a/src/v2/schemas.js b/src/v2/schemas.js new file mode 100644 index 0000000..4c3b62f --- /dev/null +++ b/src/v2/schemas.js @@ -0,0 +1,53 @@ +// JSON Schema inputs for the squad_* tools on OpenCode 2, mirroring the zod +// declarations in .opencode/plugins/orchestrate.js (OpenCode 2 takes plain +// JSON Schema and does not provide zod). Wording comes from src/squad-tools.js. + +import { ARG_DESCRIPTIONS } from "../squad-tools.js"; + +const agentEntry = { + type: "object", + properties: { + variant: { type: "string", description: ARG_DESCRIPTIONS["agent.variant"] }, + description: { type: "string", description: ARG_DESCRIPTIONS["agent.description"] }, + notes: { type: "string", description: ARG_DESCRIPTIONS["agent.notes"] }, + steps: { type: "integer", exclusiveMinimum: 0, description: ARG_DESCRIPTIONS["agent.steps"] }, + disable: { type: "boolean", description: ARG_DESCRIPTIONS["agent.disable"] }, + }, + additionalProperties: false, +}; + +/** @param {string} description */ +const agentMap = (description) => ({ + type: "object", + additionalProperties: agentEntry, + description, +}); + +export const SQUAD_COMPACT_INPUT = { + type: "object", + properties: { + task_id: { type: "string", description: ARG_DESCRIPTIONS["compact.task_id"] }, + model: { type: "string", description: ARG_DESCRIPTIONS["compact.model"] }, + }, + required: ["task_id"], + additionalProperties: false, +}; + +export const SQUAD_DUMP_INPUT = { + type: "object", + properties: { + directory: { type: "string", description: ARG_DESCRIPTIONS["dump.directory"] }, + }, + additionalProperties: false, +}; + +export const SQUAD_PATCH_INPUT = { + type: "object", + properties: { + grunts: agentMap(ARG_DESCRIPTIONS["patch.grunts"]), + drills: agentMap(ARG_DESCRIPTIONS["patch.drills"]), + allow_remove: { type: "boolean", description: ARG_DESCRIPTIONS["patch.allow_remove"] }, + directory: { type: "string", description: ARG_DESCRIPTIONS["patch.directory"] }, + }, + additionalProperties: false, +}; diff --git a/src/v2/skills.js b/src/v2/skills.js new file mode 100644 index 0000000..e3c674d --- /dev/null +++ b/src/v2/skills.js @@ -0,0 +1,95 @@ +/** + * Bundled skills for OpenCode 2 (shared verbatim with opencode-time-logger). + * + * OpenCode 1 let a plugin append a directory to `config.skills.paths`; OpenCode + * 2 has no config hook, so the plugin reads each `//SKILL.md` + * itself and registers it through `ctx.skill.transform`. The registered entry + * mirrors what OpenCode 2 builds for a discovered skill: frontmatter `name` and + * `description`, the absolute file path, and the body without frontmatter as + * `content`. + */ + +import fs from "node:fs"; +import path from "node:path"; + +/** + * Split a SKILL.md into its frontmatter fields and body. Handles the subset of + * YAML that skill frontmatter uses: `key: value`, single- or double-quoted + * values, and `>` / `|` block scalars. Anything fancier is read as plain text. + * + * Pure function — no I/O. + * + * @param {string} text + * @returns {{ fields: Record, body: string }} + */ +export function parseSkillFile(text) { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text); + if (!match) return { fields: {}, body: text }; + + /** @type {Record} */ + const fields = {}; + const lines = match[1].split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]); + if (!kv) continue; + const [, key, raw] = kv; + let value = raw.trim(); + if (value === ">" || value === "|" || value === ">-" || value === "|-") { + const block = []; + while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) { + block.push(lines[++i].trim()); + } + value = block.join(value.startsWith(">") ? " " : "\n"); + } else if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) { + try { + value = JSON.parse(value); + } catch { + value = value.slice(1, -1); + } + } else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) { + value = value.slice(1, -1).replace(/''/g, "'"); + } + fields[key] = value; + } + return { fields, body: text.slice(match[0].length) }; +} + +/** + * Read every `//SKILL.md` under the given directories. A skill + * without a frontmatter name falls back to its folder name; unreadable or + * missing directories are skipped. + * + * @param {string[]} dirs absolute directories holding one folder per skill + * @returns {Array<{ id: string, name: string, description?: string, path: string, content: string }>} + */ +export function loadSkills(dirs) { + const skills = []; + for (const dir of dirs) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const file = path.join(dir, entry.name, "SKILL.md"); + let text; + try { + text = fs.readFileSync(file, "utf8"); + } catch { + continue; + } + const { fields, body } = parseSkillFile(text); + const name = fields.name || entry.name; + skills.push({ + id: name, + name, + ...(fields.description ? { description: fields.description } : {}), + path: file, + content: body, + }); + } + } + return skills; +} diff --git a/tests/squad-tools.test.js b/tests/squad-tools.test.js new file mode 100644 index 0000000..3ff4f07 --- /dev/null +++ b/tests/squad-tools.test.js @@ -0,0 +1,180 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { + ARG_DESCRIPTIONS, + formatVariantLines, + isSubagentCaller, + TOOL_DESCRIPTIONS, + validateVariants, +} from "../src/squad-tools.js"; +import { subagentUsage, taskNotes } from "../src/task-notes.js"; +import { SQUAD_COMPACT_INPUT, SQUAD_DUMP_INPUT, SQUAD_PATCH_INPUT } from "../src/v2/schemas.js"; +import { loadSkills, parseSkillFile } from "../src/v2/skills.js"; + +describe("squad-tools", () => { + test("isSubagentCaller matches grunts and drills only", () => { + expect(isSubagentCaller("grunt-glm")).toBe(true); + expect(isSubagentCaller("drill-opus")).toBe(true); + expect(isSubagentCaller("build")).toBe(false); + expect(isSubagentCaller(undefined)).toBe(false); + }); + + test("formatVariantLines: unknown lookup, empty roster, per-model lines", () => { + const roster = { grunts: { "zai/glm": {} }, drills: { "a/opus": {}, "x/missing": {} } }; + expect(formatVariantLines(null, roster)).toContain("could not be read"); + expect(formatVariantLines({}, { grunts: {}, drills: {} })).toBe(""); + const out = formatVariantLines({ "zai/glm": ["low", "max"], "a/opus": [] }, roster); + expect(out).toContain(" a/opus: (none — leave variant unset)"); + expect(out).toContain(" x/missing: unknown (model not in this install)"); + expect(out).toContain(" zai/glm: low | max"); + expect(out.indexOf("a/opus")).toBeLessThan(out.indexOf("zai/glm")); + }); + + test("validateVariants flags only variants opencode rejects", () => { + const roster = { + grunts: { "zai/glm": { variant: "medium" }, "x/unknown": { variant: "high" } }, + drills: { "a/opus": { variant: "high" }, "a/none": { variant: "low" } }, + }; + const map = { "zai/glm": ["low", "max"], "a/opus": ["high"], "a/none": [] }; + const errors = validateVariants(roster, map); + expect(errors).toHaveLength(2); + expect(errors[0]).toContain('grunts["zai/glm"].variant "medium"'); + expect(errors[1]).toContain("(none: leave it unset)"); + expect(validateVariants(roster, null)).toEqual([]); + }); +}); + +describe("V2 schemas", () => { + test("mirror the V1 arguments and carry the shared wording", () => { + expect(SQUAD_COMPACT_INPUT.required).toEqual(["task_id"]); + expect(Object.keys(SQUAD_PATCH_INPUT.properties).sort()).toEqual([ + "allow_remove", + "directory", + "drills", + "grunts", + ]); + expect(SQUAD_PATCH_INPUT.properties.grunts.additionalProperties.properties.steps.type).toBe( + "integer", + ); + expect(SQUAD_DUMP_INPUT.properties.directory.description).toBe( + ARG_DESCRIPTIONS["dump.directory"], + ); + for (const s of [SQUAD_COMPACT_INPUT, SQUAD_DUMP_INPUT, SQUAD_PATCH_INPUT]) { + expect(s.additionalProperties).toBe(false); + } + expect(Object.keys(TOOL_DESCRIPTIONS).sort()).toEqual([ + "squad_compact", + "squad_dump", + "squad_patch", + ]); + }); +}); + +describe("task notes", () => { + const msgs = [ + { info: { role: "user", time: { created: 1 } } }, + { + info: { + role: "assistant", + time: { created: 100 }, + finish: "length", + tokens: { input: 10, output: 20, reasoning: 5, cache: { read: 0, write: 0 } }, + modelID: "m", + providerID: "p", + }, + }, + { info: { role: "assistant", time: { created: 200 } } }, + ]; + + test("subagentUsage separates newest contact from newest finished turn", () => { + expect(subagentUsage(msgs)).toEqual({ + lastHitMs: 200, + contextTokens: 35, + finish: "length", + outputTokens: 20, + reasoningTokens: 5, + }); + expect(subagentUsage([])).toEqual({ + lastHitMs: null, + contextTokens: undefined, + finish: undefined, + outputTokens: undefined, + reasoningTokens: undefined, + }); + }); + + test("taskNotes: outcome first, cache status only with model and timestamp", () => { + const usage = subagentUsage(msgs); + const base = { + taskId: "t1", + usage, + resultEmpty: false, + modelData: { "p/m": { cache_ttl_seconds: 300 } }, + limitMap: { "p/m": 1000 }, + now: 250, + timeZone: "UTC", + }; + const both = taskNotes({ ...base, providerID: "p", modelID: "m" }); + expect(both).toHaveLength(2); + expect(both[0]).toContain("[TASK OUTCOME] task_id=t1"); + expect(both[1]).toContain("[CACHE STATUS]"); + expect(taskNotes({ ...base })).toHaveLength(1); + const clean = taskNotes({ + ...base, + usage: { ...usage, finish: "stop", lastHitMs: null }, + providerID: "p", + modelID: "m", + }); + expect(clean).toEqual([]); + const providerTtl = taskNotes({ + ...base, + modelData: null, + providerID: "anthropic", + modelID: "m", + }); + expect(providerTtl.at(-1)).toContain("[CACHE STATUS]"); + }); +}); + +describe("V2 skills", () => { + test("parseSkillFile handles quoted, single-quoted and block values", () => { + expect(parseSkillFile('---\nname: a\ndescription: "say \\"hi\\""\n---\nBody').fields).toEqual({ + name: "a", + description: 'say "hi"', + }); + expect(parseSkillFile("---\nname: 'it''s'\n---\n").fields.name).toBe("it's"); + const block = parseSkillFile("---\nname: b\ndescription: |\n one\n two\n---\nBody"); + expect(block.fields.description).toBe("one\ntwo"); + expect(block.body).toBe("Body"); + expect(parseSkillFile("plain").body).toBe("plain"); + }); + + test("loadSkills reads the bundled skills and skips what it cannot read", () => { + const skills = loadSkills([path.resolve(import.meta.dirname, "..", "skills")]); + expect(skills.map((s) => s.id).sort()).toEqual([ + "squad-delegate", + "squad-draft", + "squad-redteam", + "squad-stall", + ]); + for (const s of skills) { + expect(path.isAbsolute(s.path)).toBe(true); + expect(s.content.startsWith("---")).toBe(false); + } + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "squad-skills-")); + fs.mkdirSync(path.join(dir, "bare")); + fs.writeFileSync(path.join(dir, "bare", "SKILL.md"), "no frontmatter"); + fs.mkdirSync(path.join(dir, "empty")); + fs.writeFileSync(path.join(dir, "file.txt"), "x"); + expect(loadSkills([path.join(dir, "missing"), dir])).toEqual([ + { + id: "bare", + name: "bare", + path: path.join(dir, "bare", "SKILL.md"), + content: "no frontmatter", + }, + ]); + }); +}); diff --git a/tests/v2-adapt.test.js b/tests/v2-adapt.test.js new file mode 100644 index 0000000..d838bdf --- /dev/null +++ b/tests/v2-adapt.test.js @@ -0,0 +1,274 @@ +import { describe, expect, test } from "vitest"; +import { BOOTSTRAP_MARKER } from "../src/bootstrap.js"; +import { buildLimitMap, buildVariantMap, estimateContextTokens } from "../src/context.js"; +import { formatInventory, hasSquad } from "../src/inventory.js"; +import { createTurnMemo } from "../src/message-transform.js"; +import { PROMPT_CLOSE, promptOpen } from "../src/prompt-inject.js"; +import { evaluateRetry, initGuardState, normalizeGuardConfig } from "../src/rate-limit-guard.js"; +import { + appendToResult, + applyOrchestratorTransformV2, + applySystemPartsTransform, + findV2InjectionTarget, + lastAssistantModel, + retryToStatus, + subagentOutcome, + toV1Agents, + toV1Providers, + toV1UsageMessages, +} from "../src/v2/adapt.js"; + +// Payload shapes trimmed from OpenCode 2.1.3 captures. +const v2Agents = [ + { id: "build", name: "Build", mode: "primary", hidden: false, permissions: [] }, + { + id: "grunt-glm", + name: "Grunt Glm", + mode: "subagent", + description: "cheap edits", + model: { id: "glm-5.3", providerID: "zai", variant: "low" }, + }, + { id: "general", name: "General", mode: "subagent", description: "general" }, +]; + +const v2Models = [ + { + id: "glm-5.3", + providerID: "zai", + limit: { context: 200000, output: 32000 }, + variants: [ + { id: "low", settings: { reasoningEffort: "low" } }, + { id: "max", settings: { reasoningEffort: "max" } }, + ], + }, + { id: "big-pickle", providerID: "opencode", limit: { context: 128000 }, variants: [] }, +]; + +const childContext = [ + { id: "u1", type: "user", time: { created: 1000 }, text: "do it" }, + { + id: "a1", + type: "assistant", + time: { created: 1100, completed: 1200 }, + agent: "grunt-glm", + model: { id: "glm-5.3", providerID: "zai" }, + content: [{ type: "text", text: "done" }], + finish: "stop", + tokens: { input: 900, output: 40, reasoning: 10, cache: { read: 50, write: 0 } }, + }, + { id: "i1", type: "idle", time: { created: 1300 }, outcome: "succeeded" }, +]; + +describe("agents and providers", () => { + test("toV1Agents uses the id as the name and reshapes the model", () => { + const agents = toV1Agents(v2Agents); + expect(agents[1]).toEqual({ + name: "grunt-glm", + mode: "subagent", + description: "cheap edits", + model: { providerID: "zai", modelID: "glm-5.3" }, + }); + expect(agents[0].model).toBeUndefined(); + expect(hasSquad(agents)).toBe(true); + expect(formatInventory(agents)).toContain("`grunt-glm`: cheap edits (model: zai/glm-5.3)"); + expect(toV1Agents(undefined)).toEqual([]); + }); + + test("toV1Providers feeds buildLimitMap and buildVariantMap", () => { + const providers = toV1Providers(v2Models); + expect(buildLimitMap(providers)).toMatchObject({ "zai/glm-5.3": 200000, "glm-5.3": 200000 }); + expect(buildVariantMap(providers)).toEqual({ + "zai/glm-5.3": ["low", "max"], + "opencode/big-pickle": [], + }); + expect(toV1Providers(null)).toEqual([]); + }); +}); + +describe("messages", () => { + test("toV1UsageMessages keeps what the usage scans read", () => { + const items = toV1UsageMessages(childContext); + expect(items).toHaveLength(2); + expect(items[1].info).toMatchObject({ + role: "assistant", + finish: "stop", + modelID: "glm-5.3", + providerID: "zai", + }); + expect(estimateContextTokens(items)).toEqual({ + used: 1000, + modelID: "glm-5.3", + providerID: "zai", + }); + expect(toV1UsageMessages("nope")).toEqual([]); + }); + + test("lastAssistantModel reads the newest assistant message", () => { + expect(lastAssistantModel(childContext)).toEqual({ providerID: "zai", modelID: "glm-5.3" }); + expect(lastAssistantModel([])).toBeNull(); + expect(lastAssistantModel(undefined)).toBeNull(); + }); +}); + +describe("subagent results", () => { + const result = { + output: { sessionID: "ses_child", status: "completed", output: "PONG" }, + content: [{ type: "text", text: '\nPONG\n' }], + metadata: { sessionID: "ses_child", status: "completed" }, + }; + + test("subagentOutcome reads the child session and final text", () => { + expect(subagentOutcome(result)).toEqual({ sessionID: "ses_child", output: "PONG" }); + expect(subagentOutcome({})).toEqual({ sessionID: undefined, output: "" }); + }); + + test("appendToResult adds a text part or a paragraph", () => { + expect(appendToResult(result, "NOTE").content.at(-1)).toEqual({ type: "text", text: "NOTE" }); + expect(result.content).toHaveLength(1); // not mutated + expect(appendToResult({ content: "x" }, "NOTE").content).toBe("x\n\nNOTE"); + expect(appendToResult({}, "NOTE").content).toBe("NOTE"); + }); +}); + +describe("system prompt refresh", () => { + const fenced = `${promptOpen("grunt")}\nOLD\n${PROMPT_CLOSE}\nnotes`; + + test("replaces the fenced body in the first fenced text part", () => { + const system = [ + { type: "text", text: "harness" }, + { type: "text", text: fenced }, + ]; + const r = applySystemPartsTransform(system, () => "NEW BODY"); + expect(r).toMatchObject({ applied: true, role: "grunt", changed: true }); + expect(system[1].text).toContain("NEW BODY"); + expect(system[1].text).not.toContain("OLD"); + expect(system[1].text.endsWith("notes")).toBe(true); + expect(system[0].text).toBe("harness"); + }); + + test("no fence or no loader body leaves the parts alone", () => { + const system = [{ type: "text", text: "plain" }]; + expect(applySystemPartsTransform(system, () => "X")).toEqual({ applied: false }); + const withFence = [{ type: "text", text: fenced }]; + expect(applySystemPartsTransform(withFence, () => null).applied).toBe(false); + expect(withFence[0].text).toBe(fenced); + expect(applySystemPartsTransform(undefined, () => "X")).toEqual({ applied: false }); + }); +}); + +describe("retry hook", () => { + test("retryToStatus turns the proposed delay into an absolute wake-up", () => { + const event = { + attempt: 2, + error: { type: "provider", message: "rate limit, resets in 3h", status: 429 }, + decision: { retry: true, delay: 3 * 3600_000 }, + }; + expect(retryToStatus(event, 1000)).toEqual({ + type: "retry", + attempt: 2, + message: "rate limit, resets in 3h", + next: 1000 + 3 * 3600_000, + }); + const r = evaluateRetry( + initGuardState(), + retryToStatus(event, 1000), + normalizeGuardConfig(undefined), + 429, + 1000, + ); + expect(r).toMatchObject({ trigger: true, reason: "single_wait" }); + }); + + test("a vetoed or missing delay counts as zero wait", () => { + expect(retryToStatus({ attempt: 1, decision: { retry: false } }, 5).next).toBe(5); + expect(retryToStatus({ attempt: 1, decision: { retry: true, delay: -1 } }, 5).next).toBe(5); + }); +}); + +describe("orchestrator injection", () => { + const baseOpts = () => { + const calls = { inventory: 0, usage: 0 }; + return { + calls, + opts: { + orchestratorAgent: "build", + getInventory: async () => { + calls.inventory += 1; + return "- `grunt-glm`: cheap"; + }, + getLimitMap: async () => ({ "zai/glm-5.3": 200000 }), + getHasSquad: () => true, + getUsageMessages: async () => { + calls.usage += 1; + return toV1UsageMessages(childContext); + }, + turnMemo: createTurnMemo(), + }, + }; + }; + const request = () => ({ + agent: "build", + model: { providerID: "anthropic", id: "claude-opus-5-5" }, + messages: [ + { id: "m1", role: "user", content: [{ type: "text", text: "first" }] }, + { id: "m2", role: "assistant", content: [{ type: "text", text: "ok" }] }, + { id: "m3", role: "user", content: [{ type: "text", text: "now this" }] }, + ], + }); + + test("findV2InjectionTarget picks the latest user message with text", () => { + expect(findV2InjectionTarget(request().messages)?.id).toBe("m3"); + expect(findV2InjectionTarget([{ role: "user", content: [{ type: "file" }] }])).toBeNull(); + expect(findV2InjectionTarget(undefined)).toBeNull(); + }); + + test("injects bootstrap first and the context line last on the target", async () => { + const { opts } = baseOpts(); + const event = request(); + await applyOrchestratorTransformV2(event, opts); + const content = event.messages[2].content; + expect(content[0].text).toContain(BOOTSTRAP_MARKER); + expect(content[0].text).toContain("anthropic/claude-opus-5-5"); + expect(content[1].text).toBe("now this"); + expect(content.at(-1).text).toMatch(/context/i); + expect(event.messages[0].content).toHaveLength(1); + }); + + test("replays the memoized block byte-identically within a turn", async () => { + const { opts, calls } = baseOpts(); + const first = request(); + await applyOrchestratorTransformV2(first, opts); + const second = request(); + await applyOrchestratorTransformV2(second, opts); + expect(second.messages[2].content).toEqual(first.messages[2].content); + expect(calls.inventory).toBe(1); + expect(calls.usage).toBe(1); + }); + + test("skips other agents, already-injected targets, and empty requests", async () => { + const { opts, calls } = baseOpts(); + const sub = { ...request(), agent: "grunt-glm" }; + await applyOrchestratorTransformV2(sub, opts); + expect(sub.messages[2].content).toHaveLength(1); + + const injected = request(); + injected.messages[2].content.unshift({ type: "text", text: `${BOOTSTRAP_MARKER} old` }); + await applyOrchestratorTransformV2(injected, opts); + expect(injected.messages[2].content).toHaveLength(2); + + await applyOrchestratorTransformV2({ agent: "build", messages: [] }, opts); + expect(calls.inventory).toBe(0); + }); + + test("no usage yet means no context line; no memo means rebuild", async () => { + const { opts, calls } = baseOpts(); + opts.getUsageMessages = async () => []; + opts.turnMemo = undefined; + const event = request(); + event.model = undefined; + await applyOrchestratorTransformV2(event, opts); + expect(event.messages[2].content).toHaveLength(2); + await applyOrchestratorTransformV2(request(), opts); + expect(calls.inventory).toBe(2); + }); +});