diff --git a/README.md b/README.md index c4b09bc..398e26f 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,89 @@ Restart OpenCode after making config changes. ## Limitations -**Subagents** — DCP is disabled for subagents. Subagents are not designed to be token efficient; what matters is that the final message returned to the main agent is a concise summary of findings. DCP's pruning could interfere with this summarization behavior. +**Subagents** — By default, DCP is disabled for subagents. Subagents are not designed to be token efficient; what matters is that the final message returned to the main agent is a concise summary of findings. DCP's pruning could interfere with this summarization behavior. + +However, you can enable DCP for specific sub-agents using the experimental configuration (see below). + +## Experimental Features + +### Sub-Agent DCP Support + +> **Warning:** This feature is experimental and may change or be removed in future versions. + +You can enable DCP for specific sub-agents by configuring pattern matching on their system prompts. This is useful for long-running sub-agents that would benefit from context pruning. + +```jsonc +{ + "experimental": { + "subAgents": { + // Enable DCP for sub-agents matching the configured patterns + "enabled": true, + "agents": [ + { + // Unique identifier for this configuration + "name": "explorer", + // Patterns to match in the sub-agent's system prompt (substring matching) + "systemPromptPatterns": [ + "You are an exploration agent", + "explore the codebase" + ], + "config": { + // Tools that can be pruned for this sub-agent + "prunableTools": ["Read", "Glob", "Grep"], + // Optional: Override strategies for this sub-agent + "strategies": { + "deduplication": { "enabled": true }, + "supersedeWrites": { "enabled": false }, + "purgeErrors": { "enabled": true, "turns": 2 } + }, + // Optional: Override tool settings for this sub-agent + "tools": { + "discard": { "enabled": true }, + "extract": { "enabled": false } + } + } + }, + { + "name": "researcher", + "systemPromptPatterns": ["research agent", "web search"], + "config": { + "prunableTools": ["WebFetch", "WebSearch", "Read"], + "tools": { + "discard": { "enabled": true }, + "extract": { "enabled": true } + } + } + } + ] + } + } +} +``` + +#### How It Works + +1. When a sub-agent session is detected, DCP extracts the system prompt +2. The system prompt is matched against the configured `systemPromptPatterns` +3. If a match is found, DCP is enabled for that sub-agent with the specified configuration +4. Only the tools listed in `prunableTools` can be pruned; all other tools are protected + +#### Configuration Options + +| Option | Description | +|--------|-------------| +| `name` | Unique identifier for this sub-agent configuration | +| `systemPromptPatterns` | Array of strings to match in the system prompt (substring matching) | +| `config.prunableTools` | Tools that can be pruned for this sub-agent type | +| `config.strategies` | Optional strategy overrides (deduplication, supersedeWrites, purgeErrors) | +| `config.tools` | Optional tool overrides (discard, extract) | + +#### Best Practices + +- Only enable DCP for sub-agents that have long-running sessions with many tool calls +- Keep the `prunableTools` list minimal—only include tools whose outputs become stale +- Test thoroughly, as sub-agent behavior may change with pruning enabled +- Consider disabling the `extract` tool for sub-agents to keep things simple ## License diff --git a/dcp.schema.json b/dcp.schema.json index 6e0b01a..ab660bc 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -207,6 +207,129 @@ } } } + }, + "experimental": { + "type": "object", + "description": "Experimental features (may change or be removed in future versions)", + "additionalProperties": false, + "properties": { + "subAgents": { + "type": "object", + "description": "Enable DCP for specific sub-agents (experimental)", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable DCP for sub-agents matching the configured patterns" + }, + "agents": { + "type": "array", + "description": "List of sub-agent configurations", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "systemPromptPatterns", "config"], + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for this sub-agent configuration" + }, + "systemPromptPatterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Patterns to match in the system prompt to identify this sub-agent type (substring matching)" + }, + "config": { + "type": "object", + "additionalProperties": false, + "required": ["prunableTools"], + "properties": { + "prunableTools": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tools that can be pruned for this sub-agent type" + }, + "strategies": { + "type": "object", + "description": "Override strategies for this sub-agent", + "additionalProperties": false, + "properties": { + "deduplication": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable deduplication for this sub-agent" + } + } + }, + "supersedeWrites": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable supersede writes for this sub-agent" + } + } + }, + "purgeErrors": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable purge errors for this sub-agent" + }, + "turns": { + "type": "number", + "description": "Number of turns before errors are purged" + } + } + } + } + }, + "tools": { + "type": "object", + "description": "Override tool settings for this sub-agent", + "additionalProperties": false, + "properties": { + "discard": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable discard tool for this sub-agent" + } + } + }, + "extract": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable extract tool for this sub-agent" + } + } + } + } + } + } + } + } + } + } + } + } + } } } } diff --git a/lib/config.ts b/lib/config.ts index 1547dad..040939d 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -45,6 +45,44 @@ export interface TurnProtection { turns: number } +// Experimental: Sub-agent DCP configuration +export interface SubAgentConfig { + // Tools that can be pruned for this sub-agent type + prunableTools: string[] + // Override strategies for this sub-agent type + strategies?: { + deduplication?: { enabled?: boolean } + supersedeWrites?: { enabled?: boolean } + purgeErrors?: { enabled?: boolean; turns?: number } + } + // Override tools for this sub-agent type + tools?: { + discard?: { enabled?: boolean } + extract?: { enabled?: boolean } + } +} + +export interface SubAgentEntry { + // Unique identifier for this sub-agent configuration + name: string + // Patterns to match in the system prompt to identify this sub-agent type + // Uses simple substring matching + systemPromptPatterns: string[] + // Configuration for this sub-agent type + config: SubAgentConfig +} + +export interface ExperimentalSubAgents { + // Enable DCP for sub-agents (experimental) + enabled: boolean + // Sub-agent configurations + agents: SubAgentEntry[] +} + +export interface Experimental { + subAgents: ExperimentalSubAgents +} + export interface PluginConfig { enabled: boolean debug: boolean @@ -58,6 +96,7 @@ export interface PluginConfig { supersedeWrites: SupersedeWrites purgeErrors: PurgeErrors } + experimental: Experimental } const DEFAULT_PROTECTED_TOOLS = [ @@ -109,6 +148,11 @@ export const VALID_CONFIG_KEYS = new Set([ "strategies.purgeErrors.enabled", "strategies.purgeErrors.turns", "strategies.purgeErrors.protectedTools", + // experimental + "experimental", + "experimental.subAgents", + "experimental.subAgents.enabled", + "experimental.subAgents.agents", ]) // Extract all key paths from a config object for validation @@ -347,6 +391,33 @@ function validateConfigTypes(config: Record): ValidationError[] { } } + // Experimental validators + const experimental = config.experimental + if (experimental) { + if (experimental.subAgents) { + if ( + experimental.subAgents.enabled !== undefined && + typeof experimental.subAgents.enabled !== "boolean" + ) { + errors.push({ + key: "experimental.subAgents.enabled", + expected: "boolean", + actual: typeof experimental.subAgents.enabled, + }) + } + if ( + experimental.subAgents.agents !== undefined && + !Array.isArray(experimental.subAgents.agents) + ) { + errors.push({ + key: "experimental.subAgents.agents", + expected: "array", + actual: typeof experimental.subAgents.agents, + }) + } + } + } + return errors } @@ -434,6 +505,12 @@ const defaultConfig: PluginConfig = { protectedTools: [...DEFAULT_PROTECTED_TOOLS], }, }, + experimental: { + subAgents: { + enabled: false, + agents: [], + }, + }, } const GLOBAL_CONFIG_DIR = join(homedir(), ".config", "opencode") @@ -660,6 +737,20 @@ function mergeCommands( return override as boolean } +function mergeExperimental( + base: PluginConfig["experimental"], + override?: Partial, +): PluginConfig["experimental"] { + if (!override) return base + + return { + subAgents: { + enabled: override.subAgents?.enabled ?? base.subAgents.enabled, + agents: override.subAgents?.agents ?? base.subAgents.agents, + }, + } +} + function deepCloneConfig(config: PluginConfig): PluginConfig { return { ...config, @@ -687,6 +778,22 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { protectedTools: [...config.strategies.purgeErrors.protectedTools], }, }, + experimental: { + subAgents: { + enabled: config.experimental.subAgents.enabled, + agents: config.experimental.subAgents.agents.map((agent) => ({ + name: agent.name, + systemPromptPatterns: [...agent.systemPromptPatterns], + config: { + prunableTools: [...agent.config.prunableTools], + strategies: agent.config.strategies + ? { ...agent.config.strategies } + : undefined, + tools: agent.config.tools ? { ...agent.config.tools } : undefined, + }, + })), + }, + }, } } @@ -730,6 +837,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { ], tools: mergeTools(config.tools, result.data.tools as any), strategies: mergeStrategies(config.strategies, result.data.strategies as any), + experimental: mergeExperimental(config.experimental, result.data.experimental as any), } } } else { @@ -773,6 +881,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { ], tools: mergeTools(config.tools, result.data.tools as any), strategies: mergeStrategies(config.strategies, result.data.strategies as any), + experimental: mergeExperimental(config.experimental, result.data.experimental as any), } } } @@ -813,9 +922,31 @@ export function getConfig(ctx: PluginInput): PluginConfig { ], tools: mergeTools(config.tools, result.data.tools as any), strategies: mergeStrategies(config.strategies, result.data.strategies as any), + experimental: mergeExperimental(config.experimental, result.data.experimental as any), } } } return config } + +// Helper function to get effective config for a sub-agent based on system prompt +export function getSubAgentConfig( + config: PluginConfig, + systemPrompt: string, +): { enabled: boolean; agentConfig: SubAgentEntry | null } { + if (!config.experimental.subAgents.enabled) { + return { enabled: false, agentConfig: null } + } + + for (const agent of config.experimental.subAgents.agents) { + const matchesPattern = agent.systemPromptPatterns.some((pattern) => + systemPrompt.includes(pattern), + ) + if (matchesPattern) { + return { enabled: true, agentConfig: agent } + } + } + + return { enabled: false, agentConfig: null } +} diff --git a/lib/hooks.ts b/lib/hooks.ts index 0533e37..8cfba19 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -22,8 +22,15 @@ export function createSystemPromptHandler( config: PluginConfig, ) { return async (_input: unknown, output: { system: string[] }) => { + // For sub-agents, check if DCP is enabled via experimental config if (state.isSubAgent) { - return + if (!state.subAgentState.dcpEnabled) { + logger.info("Skipping DCP system prompt for sub-agent (not enabled in config)") + return + } + logger.info("Sub-agent DCP enabled, injecting system prompt", { + matchedAgent: state.subAgentState.matchedConfig?.name, + }) } const systemText = output.system.join("\n") @@ -32,8 +39,20 @@ export function createSystemPromptHandler( return } - const discardEnabled = config.tools.discard.enabled - const extractEnabled = config.tools.extract.enabled + // Determine which tools are enabled for this session + let discardEnabled = config.tools.discard.enabled + let extractEnabled = config.tools.extract.enabled + + // For sub-agents, check if tools are overridden in the config + if (state.isSubAgent && state.subAgentState.matchedConfig?.config.tools) { + const toolsOverride = state.subAgentState.matchedConfig.config.tools + if (toolsOverride.discard?.enabled !== undefined) { + discardEnabled = toolsOverride.discard.enabled + } + if (toolsOverride.extract?.enabled !== undefined) { + extractEnabled = toolsOverride.extract.enabled + } + } let promptName: string if (discardEnabled && extractEnabled) { @@ -58,21 +77,26 @@ export function createChatMessageTransformHandler( config: PluginConfig, ) { return async (input: {}, output: { messages: WithParts[] }) => { - await checkSession(client, state, logger, output.messages) + await checkSession(client, state, logger, config, output.messages) - if (state.isSubAgent) { + // For sub-agents, check if DCP is enabled via experimental config + if (state.isSubAgent && !state.subAgentState.dcpEnabled) { + logger.debug("Skipping DCP for sub-agent (not enabled in config)") return } - syncToolCache(state, config, logger, output.messages) + // Get effective config for this session (may have sub-agent overrides) + const effectiveConfig = getEffectiveConfig(config, state) - deduplicate(state, logger, config, output.messages) - supersedeWrites(state, logger, config, output.messages) - purgeErrors(state, logger, config, output.messages) + syncToolCache(state, effectiveConfig, logger, output.messages) - prune(state, logger, config, output.messages) + deduplicate(state, logger, effectiveConfig, output.messages) + supersedeWrites(state, logger, effectiveConfig, output.messages) + purgeErrors(state, logger, effectiveConfig, output.messages) - insertPruneToolContext(state, config, logger, output.messages) + prune(state, logger, effectiveConfig, output.messages) + + insertPruneToolContext(state, effectiveConfig, logger, output.messages) if (state.sessionId) { await logger.saveContext(state.sessionId, output.messages) @@ -80,6 +104,64 @@ export function createChatMessageTransformHandler( } } +// Get effective config with sub-agent overrides applied +function getEffectiveConfig(config: PluginConfig, state: SessionState): PluginConfig { + if (!state.isSubAgent || !state.subAgentState.matchedConfig) { + return config + } + + const agentConfig = state.subAgentState.matchedConfig.config + + // Create a copy of the config with sub-agent overrides + const effectiveConfig: PluginConfig = { + ...config, + tools: { + ...config.tools, + settings: { + ...config.tools.settings, + // For sub-agents, only the tools defined in prunableTools should be prunable + // All other tools should be protected + protectedTools: [ + ...config.tools.settings.protectedTools, + ], + }, + discard: { + ...config.tools.discard, + ...(agentConfig.tools?.discard || {}), + }, + extract: { + ...config.tools.extract, + ...(agentConfig.tools?.extract || {}), + }, + }, + strategies: { + deduplication: { + ...config.strategies.deduplication, + enabled: + agentConfig.strategies?.deduplication?.enabled ?? + config.strategies.deduplication.enabled, + }, + supersedeWrites: { + ...config.strategies.supersedeWrites, + enabled: + agentConfig.strategies?.supersedeWrites?.enabled ?? + config.strategies.supersedeWrites.enabled, + }, + purgeErrors: { + ...config.strategies.purgeErrors, + enabled: + agentConfig.strategies?.purgeErrors?.enabled ?? + config.strategies.purgeErrors.enabled, + turns: + agentConfig.strategies?.purgeErrors?.turns ?? + config.strategies.purgeErrors.turns, + }, + }, + } + + return effectiveConfig +} + export function createCommandExecuteHandler( client: any, state: SessionState, diff --git a/lib/state/state.ts b/lib/state/state.ts index e68ecf8..933714d 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,13 +1,16 @@ -import type { SessionState, ToolParameterEntry, WithParts } from "./types" +import type { SessionState, ToolParameterEntry, WithParts, SubAgentState } from "./types" import type { Logger } from "../logger" +import type { PluginConfig } from "../config" +import { getSubAgentConfig } from "../config" import { loadSessionState } from "./persistence" -import { isSubAgentSession } from "./utils" +import { getSubAgentSessionInfo } from "./utils" import { getLastUserMessage, isMessageCompacted } from "../shared-utils" export const checkSession = async ( client: any, state: SessionState, logger: Logger, + config: PluginConfig, messages: WithParts[], ): Promise => { const lastUserMessage = getLastUserMessage(messages) @@ -20,7 +23,7 @@ export const checkSession = async ( if (state.sessionId === null || state.sessionId !== lastSessionId) { logger.info(`Session changed: ${state.sessionId} -> ${lastSessionId}`) try { - await ensureSessionInitialized(client, state, lastSessionId, logger, messages) + await ensureSessionInitialized(client, state, lastSessionId, logger, config, messages) } catch (err: any) { logger.error("Failed to initialize session state", { error: err.message }) } @@ -39,10 +42,19 @@ export const checkSession = async ( state.currentTurn = countTurns(state, messages) } +function createDefaultSubAgentState(): SubAgentState { + return { + dcpEnabled: false, + matchedConfig: null, + systemPrompt: null, + } +} + export function createSessionState(): SessionState { return { sessionId: null, isSubAgent: false, + subAgentState: createDefaultSubAgentState(), prune: { toolIds: [], }, @@ -62,6 +74,7 @@ export function createSessionState(): SessionState { export function resetSessionState(state: SessionState): void { state.sessionId = null state.isSubAgent = false + state.subAgentState = createDefaultSubAgentState() state.prune = { toolIds: [], } @@ -82,6 +95,7 @@ export async function ensureSessionInitialized( state: SessionState, sessionId: string, logger: Logger, + config: PluginConfig, messages: WithParts[], ): Promise { if (state.sessionId === sessionId) { @@ -94,9 +108,23 @@ export async function ensureSessionInitialized( resetSessionState(state) state.sessionId = sessionId - const isSubAgent = await isSubAgentSession(client, sessionId) - state.isSubAgent = isSubAgent - logger.info("isSubAgent = " + isSubAgent) + const sessionInfo = await getSubAgentSessionInfo(client, sessionId) + state.isSubAgent = sessionInfo.isSubAgent + logger.info("isSubAgent = " + sessionInfo.isSubAgent) + + // Initialize sub-agent state if this is a sub-agent + if (sessionInfo.isSubAgent && sessionInfo.systemPrompt) { + const subAgentConfig = getSubAgentConfig(config, sessionInfo.systemPrompt) + state.subAgentState = { + dcpEnabled: subAgentConfig.enabled, + matchedConfig: subAgentConfig.agentConfig, + systemPrompt: sessionInfo.systemPrompt, + } + logger.info("Sub-agent DCP config", { + dcpEnabled: subAgentConfig.enabled, + matchedAgent: subAgentConfig.agentConfig?.name || null, + }) + } state.lastCompaction = findLastCompactionTimestamp(messages) state.currentTurn = countTurns(state, messages) diff --git a/lib/state/types.ts b/lib/state/types.ts index 1e41170..0da9fd2 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -1,4 +1,5 @@ import { Message, Part } from "@opencode-ai/sdk/v2" +import type { SubAgentEntry } from "../config" export interface WithParts { info: Message @@ -24,9 +25,21 @@ export interface Prune { toolIds: string[] } +// Sub-agent state information +export interface SubAgentState { + // Whether DCP is enabled for this sub-agent (based on config matching) + dcpEnabled: boolean + // The matched sub-agent configuration (if any) + matchedConfig: SubAgentEntry | null + // The system prompt used for matching (cached for debugging) + systemPrompt: string | null +} + export interface SessionState { sessionId: string | null isSubAgent: boolean + // Sub-agent specific DCP state (experimental) + subAgentState: SubAgentState prune: Prune stats: SessionStats toolParameters: Map diff --git a/lib/state/utils.ts b/lib/state/utils.ts index 4cc10ce..d833298 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -1,3 +1,9 @@ +export interface SubAgentSessionInfo { + isSubAgent: boolean + parentID: string | null + systemPrompt: string | null +} + export async function isSubAgentSession(client: any, sessionID: string): Promise { try { const result = await client.session.get({ path: { id: sessionID } }) @@ -6,3 +12,29 @@ export async function isSubAgentSession(client: any, sessionID: string): Promise return false } } + +export async function getSubAgentSessionInfo( + client: any, + sessionID: string, +): Promise { + try { + const result = await client.session.get({ path: { id: sessionID } }) + const isSubAgent = !!result.data?.parentID + const parentID = result.data?.parentID || null + + // Get system prompt from session if available + let systemPrompt: string | null = null + if (isSubAgent && result.data?.system) { + // system can be a string or array of strings + if (Array.isArray(result.data.system)) { + systemPrompt = result.data.system.join("\n") + } else if (typeof result.data.system === "string") { + systemPrompt = result.data.system + } + } + + return { isSubAgent, parentID, systemPrompt } + } catch (error: any) { + return { isSubAgent: false, parentID: null, systemPrompt: null } + } +} diff --git a/lib/strategies/tools.ts b/lib/strategies/tools.ts index 44f6742..55f94ca 100644 --- a/lib/strategies/tools.ts +++ b/lib/strategies/tools.ts @@ -59,7 +59,7 @@ async function executePruneOperation( }) const messages: WithParts[] = messagesResponse.data || messagesResponse - await ensureSessionInitialized(ctx.client, state, sessionId, logger, messages) + await ensureSessionInitialized(ctx.client, state, sessionId, logger, config, messages) const currentParams = getCurrentParams(state, messages, logger) const toolIdList: string[] = buildToolIdList(state, messages, logger)