diff --git a/package-lock.json b/package-lock.json index b4ba6e5..e36f89b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-dynamic-subagents", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-dynamic-subagents", - "version": "0.2.4", + "version": "0.2.5", "license": "MIT", "dependencies": { "@opencode-ai/plugin": "^1.2.27", diff --git a/package.json b/package.json index 9b98fc5..57e5ee6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "opencode-dynamic-subagents", - "version": "0.2.4", + "version": "0.2.5", "description": "OpenCode plugin that adds policy-controlled dynamic subagents with runtime model and variant selection.", "type": "module", "license": "MIT", diff --git a/src/plugin.ts b/src/plugin.ts index b04b192..17ca3b9 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -4,7 +4,12 @@ import { buildTaskDescription, collectConfiguredSubagents, injectRuntimeSubagent import { createTaskTool } from "./task-tool.js" import type { ConfiguredSubagentSummary } from "./types.js" -type PluginConfigShape = Parameters[0] +type PluginConfigShape = Parameters[0] & { + agent?: Record + experimental?: { + primary_tools?: readonly string[] + } +} type SystemTransformOutput = { system: string[] } @@ -12,11 +17,15 @@ type SystemTransformOutput = { type RuntimeState = { configuredSubagents: readonly ConfiguredSubagentSummary[] runtimeAgentName?: string + primaryTools: readonly string[] + taskPermissionAgents: ReadonlySet } export const DynamicSubAgentsPlugin: Plugin = (input) => { const state: RuntimeState = { configuredSubagents: [], + primaryTools: [], + taskPermissionAgents: new Set(), } const taskTool = createTaskTool(input, state) @@ -26,6 +35,8 @@ export const DynamicSubAgentsPlugin: Plugin = (input) => { const dynamicConfig = await loadDynamicSubAgentsConfig() state.configuredSubagents = collectConfiguredSubagents(config) + state.primaryTools = config.experimental?.primary_tools ?? [] + state.taskPermissionAgents = collectTaskPermissionAgents(config.agent) delete state.runtimeAgentName if (!dynamicConfig) return @@ -49,6 +60,9 @@ export const DynamicSubAgentsPlugin: Plugin = (input) => { } state.runtimeAgentName = policy.runtimeAgentName + if (hasExplicitTaskPermission(policy.permission)) { + state.taskPermissionAgents = new Set(state.taskPermissionAgents).add(policy.runtimeAgentName) + } state.configuredSubagents = collectConfiguredSubagents(config, policy.runtimeAgentName) }, tool: { @@ -63,4 +77,20 @@ export const DynamicSubAgentsPlugin: Plugin = (input) => { }) } +export function collectTaskPermissionAgents(agents: PluginConfigShape["agent"]): ReadonlySet { + const result = new Set() + + for (const [name, agent] of Object.entries(agents ?? {})) { + if (hasExplicitTaskPermission(agent?.permission)) { + result.add(name) + } + } + + return result +} + +export function hasExplicitTaskPermission(permission: unknown): boolean { + return typeof permission === "object" && permission !== null && "task" in permission +} + export default DynamicSubAgentsPlugin diff --git a/src/task-tool.ts b/src/task-tool.ts index 004d6b4..942fe44 100644 --- a/src/task-tool.ts +++ b/src/task-tool.ts @@ -35,6 +35,8 @@ type TaskToolArgs = { export type TaskToolState = { configuredSubagents: readonly ConfiguredSubagentSummary[] runtimeAgentName?: string + primaryTools: readonly string[] + taskPermissionAgents: ReadonlySet } export function createTaskTool(pluginInput: PluginInput, state: TaskToolState): ToolDefinition { @@ -77,20 +79,20 @@ export function createTaskTool(pluginInput: PluginInput, state: TaskToolState): throw new Error("Dynamic subagents require ~/.config/opencode/dynamicSubAgents.json.") } + const targetAgent = resolveTargetAgent(args, state, policy, isDynamic) + const hasTaskPermission = resolveHasTaskPermission(args, state, policy, isDynamic) const parentAssistant = await loadParentAssistantMessage(pluginInput, context) const session = await getOrCreateTaskSession(pluginInput, context, args) const model = resolveModel(args, policy, parentAssistant, isDynamic) const variant = resolveVariant(args, policy, isDynamic) - const targetAgent = resolveTargetAgent(args, state, policy, isDynamic) const prompt = resolvePrompt(args, policy, state) context.metadata({ title: args.description, metadata: { sessionId: session.id, - model: model ?? "agent-default", - variant, - targetAgent, + ...(model ? { model } : {}), + ...(variant ? { variant } : {}), }, }) @@ -103,11 +105,11 @@ export function createTaskTool(pluginInput: PluginInput, state: TaskToolState): } = { agent: targetAgent, parts: [{ type: "text", text: prompt }], + tools: buildToolSelection(hasTaskPermission, state.primaryTools), } if (model) body.model = model if (variant) body.variant = variant - if (isDynamic) body.tools = { task: false } const result = await pluginInput.client.session.prompt({ path: { id: session.id }, @@ -210,6 +212,20 @@ function resolveTargetAgent( return policy.runtimeAgentName } +function resolveHasTaskPermission( + args: TaskToolArgs, + state: TaskToolState, + policy: DynamicSubAgentPolicy | undefined, + isDynamic: boolean, +): boolean { + if (isDynamic) { + if (!policy) throw new Error("Dynamic subagent policy is unavailable.") + return state.taskPermissionAgents.has(policy.runtimeAgentName) + } + + return state.taskPermissionAgents.has(args.subagent_type) +} + function resolvePrompt(args: TaskToolArgs, policy: DynamicSubAgentPolicy | undefined, state: TaskToolState): string { if (!args.subagent_description) return args.prompt if (!policy) throw new Error("Dynamic subagent policy is unavailable.") @@ -265,3 +281,12 @@ function extractTextResult(message: SessionMessageResponse): string { const textPart = [...message.parts].reverse().find((part) => part.type === "text") return textPart?.type === "text" ? textPart.text : "Subagent completed without a text result." } + +export function buildToolSelection(hasTaskPermission: boolean, primaryTools: readonly string[]): Record { + return { + todowrite: false, + todoread: false, + ...(hasTaskPermission ? {} : { task: false }), + ...Object.fromEntries(primaryTools.map((toolID) => [toolID, false])), + } +} diff --git a/test/task-tool.test.ts b/test/task-tool.test.ts new file mode 100644 index 0000000..052aaa9 --- /dev/null +++ b/test/task-tool.test.ts @@ -0,0 +1,48 @@ +import * as assert from "node:assert/strict" +import { test } from "node:test" + +import { collectTaskPermissionAgents, hasExplicitTaskPermission } from "../src/plugin.js" +import { buildToolSelection } from "../src/task-tool.js" + +void test("buildToolSelection disables todo tools and primary tools for child sessions", () => { + assert.deepEqual(buildToolSelection(false, ["bash", "edit"]), { + todowrite: false, + todoread: false, + task: false, + bash: false, + edit: false, + }) +}) + +void test("buildToolSelection preserves task when the target agent explicitly allows it", () => { + assert.deepEqual(buildToolSelection(true, ["bash"]), { + todowrite: false, + todoread: false, + bash: false, + }) +}) + +void test("hasExplicitTaskPermission only matches object permissions with a task key", () => { + assert.equal(hasExplicitTaskPermission(undefined), false) + assert.equal(hasExplicitTaskPermission("allow"), false) + assert.equal(hasExplicitTaskPermission({ read: "allow" }), false) + assert.equal(hasExplicitTaskPermission({ task: "allow" }), true) +}) + +void test("collectTaskPermissionAgents finds only agents with explicit task permissions", () => { + const result = collectTaskPermissionAgents({ + review: { + permission: { + read: "allow", + }, + }, + orchestrator: { + permission: { + task: "allow", + }, + }, + missing: undefined, + }) + + assert.deepEqual([...result], ["orchestrator"]) +})