Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
32 changes: 31 additions & 1 deletion src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,28 @@ import { buildTaskDescription, collectConfiguredSubagents, injectRuntimeSubagent
import { createTaskTool } from "./task-tool.js"
import type { ConfiguredSubagentSummary } from "./types.js"

type PluginConfigShape = Parameters<typeof collectConfiguredSubagents>[0]
type PluginConfigShape = Parameters<typeof collectConfiguredSubagents>[0] & {
agent?: Record<string, { permission?: unknown } | undefined>
experimental?: {
primary_tools?: readonly string[]
}
}
type SystemTransformOutput = {
system: string[]
}

type RuntimeState = {
configuredSubagents: readonly ConfiguredSubagentSummary[]
runtimeAgentName?: string
primaryTools: readonly string[]
taskPermissionAgents: ReadonlySet<string>
}

export const DynamicSubAgentsPlugin: Plugin = (input) => {
const state: RuntimeState = {
configuredSubagents: [],
primaryTools: [],
taskPermissionAgents: new Set<string>(),
}

const taskTool = createTaskTool(input, state)
Expand All @@ -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
Expand All @@ -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: {
Expand All @@ -63,4 +77,20 @@ export const DynamicSubAgentsPlugin: Plugin = (input) => {
})
}

export function collectTaskPermissionAgents(agents: PluginConfigShape["agent"]): ReadonlySet<string> {
const result = new Set<string>()

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
35 changes: 30 additions & 5 deletions src/task-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type TaskToolArgs = {
export type TaskToolState = {
configuredSubagents: readonly ConfiguredSubagentSummary[]
runtimeAgentName?: string
primaryTools: readonly string[]
taskPermissionAgents: ReadonlySet<string>
}

export function createTaskTool(pluginInput: PluginInput, state: TaskToolState): ToolDefinition {
Expand Down Expand Up @@ -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 } : {}),
},
})

Expand All @@ -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 },
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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<string, boolean> {
return {
todowrite: false,
todoread: false,
...(hasTaskPermission ? {} : { task: false }),
...Object.fromEntries(primaryTools.map((toolID) => [toolID, false])),
}
}
48 changes: 48 additions & 0 deletions test/task-tool.test.ts
Original file line number Diff line number Diff line change
@@ -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"])
})