feat(agents): wire agent_definitions and opencode.json agents into precedence chain

- Modified agent-config-handler.ts to load and integrate both new agent sources
- Added loadAgentDefinitions() and readOpencodeConfigAgents() calls in loading phase
- Integrated both sources into agent precedence chains (both Sisyphus-enabled and disabled paths)
- Added detailed logging for new agent sources
- Added filtering logic to respect disabled_agents configuration
- Extended agent-config-handler.test.ts with 7 new integration tests
- All tests passing (18/18 integration, 65/65 loader suite)

Wave 3 of agent definitions enhancement complete.
This commit is contained in:
Brandon Webb
2026-04-14 13:34:30 -04:00
committed by YeonGyu-Kim
parent 5755a90c3b
commit 39bda91bc7
5 changed files with 298 additions and 4 deletions
@@ -1,2 +1,5 @@
export * from "./types"
export * from "./loader"
export * from "./agent-definitions-loader"
export * from "./opencode-config-agents-reader"
export * from "./json-agent-loader"
@@ -229,6 +229,34 @@ describe("readOpencodeConfigAgents", () => {
fs.rmSync(tempDir, { recursive: true })
})
it("supports agent key as fallback when agents key is not present", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-"))
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
const configPath = path.join(opencodeDir, "opencode.json")
fs.writeFileSync(
configPath,
JSON.stringify({
agent: {
"fallback-agent": {
description: "Using agent key",
mode: "subagent",
prompt: "Fallback prompt",
},
},
})
)
const result = readOpencodeConfigAgents(tempDir)
expect(result).toHaveProperty("fallback-agent")
expect(result["fallback-agent"].description).toBe("(opencode-config) Using agent key")
expect(result["fallback-agent"].prompt).toBe("Fallback prompt")
fs.rmSync(tempDir, { recursive: true })
})
it("prioritizes project-level opencode.json over user-level", () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-project-"))
const projectOpencodeDir = path.join(projectDir, ".opencode")
@@ -9,6 +9,7 @@ import type { ClaudeCodeAgentConfig } from "./types"
interface OpencodeConfigWithAgents {
agents?: Record<string, unknown>
agent?: Record<string, unknown>
agent_definitions?: string | string[]
}
@@ -103,8 +104,10 @@ export function readOpencodeConfigAgents(directory: string): Record<string, Clau
const configDir = path.dirname(configPath)
if (parseResult.data.agents && typeof parseResult.data.agents === "object") {
for (const [agentName, agentData] of Object.entries(parseResult.data.agents)) {
const agentsToLoad = parseResult.data.agents || parseResult.data.agent
if (agentsToLoad && typeof agentsToLoad === "object") {
for (const [agentName, agentData] of Object.entries(agentsToLoad)) {
const converted = convertInlineAgent(agentData)
if (converted) {
result[agentName] = converted
@@ -61,6 +61,8 @@ describe("applyAgentConfig builtin override protection", () => {
let discoverGlobalAgentsSkillsSpy: ReturnType<typeof spyOn>
let loadUserAgentsSpy: ReturnType<typeof spyOn>
let loadProjectAgentsSpy: ReturnType<typeof spyOn>
let loadAgentDefinitionsSpy: ReturnType<typeof spyOn>
let readOpencodeConfigAgentsSpy: ReturnType<typeof spyOn>
let migrateAgentConfigSpy: ReturnType<typeof spyOn>
let logSpy: ReturnType<typeof spyOn>
@@ -140,6 +142,11 @@ describe("applyAgentConfig builtin override protection", () => {
loadUserAgentsSpy = spyOn(agentLoader, "loadUserAgents").mockReturnValue({})
loadProjectAgentsSpy = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({})
loadAgentDefinitionsSpy = spyOn(agentLoader, "loadAgentDefinitions").mockReturnValue({})
readOpencodeConfigAgentsSpy = spyOn(
agentLoader,
"readOpencodeConfigAgents",
).mockReturnValue({})
migrateAgentConfigSpy = spyOn(shared, "migrateAgentConfig").mockImplementation(
(config: Record<string, unknown>) => config,
@@ -159,6 +166,8 @@ describe("applyAgentConfig builtin override protection", () => {
discoverGlobalAgentsSkillsSpy.mockRestore()
loadUserAgentsSpy.mockRestore()
loadProjectAgentsSpy.mockRestore()
loadAgentDefinitionsSpy.mockRestore()
readOpencodeConfigAgentsSpy.mockRestore()
migrateAgentConfigSpy.mockRestore()
logSpy.mockRestore()
})
@@ -441,4 +450,208 @@ describe("applyAgentConfig builtin override protection", () => {
]),
)
})
describe("agent_definitions and opencode.json integration", () => {
test("agent_definitions agents appear in output", async () => {
// given
loadAgentDefinitionsSpy.mockReturnValue({
"my-custom-agent": {
name: "my-custom-agent",
prompt: "test custom agent from agent_definitions",
mode: "subagent",
},
})
const pluginConfig = createPluginConfig()
pluginConfig.agent_definitions = ["/fake/path/agent.md"]
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig,
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(result["my-custom-agent"]).toBeDefined()
expect(result["my-custom-agent"]?.prompt).toBe("test custom agent from agent_definitions")
})
test("opencode.json agents appear in output", async () => {
// given
readOpencodeConfigAgentsSpy.mockReturnValue({
"opencode-agent": {
name: "opencode-agent",
prompt: "test opencode config agent",
mode: "subagent",
description: "(opencode-config) OC",
},
})
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(result["opencode-agent"]).toBeDefined()
expect(result["opencode-agent"]?.prompt).toBe("test opencode config agent")
expect(result["opencode-agent"]?.description).toBe("(opencode-config) OC")
})
test("agent_definitions agents subject to disabled_agents filtering", async () => {
// given
loadAgentDefinitionsSpy.mockReturnValue({
"disabled-custom-agent": {
name: "disabled-custom-agent",
prompt: "this should be filtered",
mode: "subagent",
},
})
const pluginConfig = createPluginConfig()
pluginConfig.agent_definitions = ["/fake/path/agent.md"]
pluginConfig.disabled_agents = ["disabled-custom-agent"]
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig,
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(result["disabled-custom-agent"]).toBeUndefined()
})
test("agent_definitions cannot override builtin agents", async () => {
// given
loadAgentDefinitionsSpy.mockReturnValue({
oracle: {
name: "oracle",
prompt: "evil override prompt",
mode: "subagent",
},
})
const pluginConfig = createPluginConfig()
pluginConfig.agent_definitions = ["/fake/path/agent.md"]
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig,
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(result.oracle).toBeDefined()
expect(result.oracle?.prompt).not.toBe("evil override prompt")
})
test("precedence: configAgents override agent_definitions", async () => {
// given
loadAgentDefinitionsSpy.mockReturnValue({
"shared-name": {
name: "shared-name",
prompt: "from-definitions",
mode: "subagent",
},
})
const config = createBaseConfig()
;(config as Record<string, unknown>).agent = {
"shared-name": {
name: "shared-name",
prompt: "from-config",
mode: "subagent",
},
}
const pluginConfig = createPluginConfig()
pluginConfig.agent_definitions = ["/fake/path/agent.md"]
// when
const result = await applyAgentConfig({
config,
pluginConfig,
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(result["shared-name"]).toBeDefined()
expect(result["shared-name"]?.prompt).toBe("from-config")
})
test("precedence: agent_definitions overrides project agents", async () => {
// given
loadProjectAgentsSpy.mockReturnValue({
"shared-name": {
name: "shared-name",
prompt: "from-project",
mode: "subagent",
},
})
loadAgentDefinitionsSpy.mockReturnValue({
"shared-name": {
name: "shared-name",
prompt: "from-definitions",
mode: "subagent",
},
})
const pluginConfig = createPluginConfig()
pluginConfig.agent_definitions = ["/fake/path/agent.md"]
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig,
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(result["shared-name"]).toBeDefined()
expect(result["shared-name"]?.prompt).toBe("from-definitions")
})
test("both Sisyphus-enabled and disabled paths include new sources", async () => {
// given
loadAgentDefinitionsSpy.mockReturnValue({
"definitions-agent": {
name: "definitions-agent",
prompt: "from agent_definitions",
mode: "subagent",
},
})
readOpencodeConfigAgentsSpy.mockReturnValue({
"opencode-agent": {
name: "opencode-agent",
prompt: "from opencode.json",
mode: "subagent",
},
})
const pluginConfig = createPluginConfig()
pluginConfig.agent_definitions = ["/fake/path/agent.md"]
if (pluginConfig.sisyphus_agent) {
pluginConfig.sisyphus_agent.planner_enabled = false
}
// when
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig,
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
expect(result["definitions-agent"]).toBeDefined()
expect(result["definitions-agent"]?.prompt).toBe("from agent_definitions")
expect(result["opencode-agent"]).toBeDefined()
expect(result["opencode-agent"]?.prompt).toBe("from opencode.json")
})
})
})
+49 -2
View File
@@ -14,7 +14,14 @@ import {
discoverProjectClaudeSkills,
discoverUserClaudeSkills,
} from "../features/opencode-skill-loader";
import { loadProjectAgents, loadUserAgents, loadOpencodeGlobalAgents, loadOpencodeProjectAgents } from "../features/claude-code-agent-loader";
import {
loadProjectAgents,
loadUserAgents,
loadOpencodeGlobalAgents,
loadOpencodeProjectAgents,
loadAgentDefinitions,
readOpencodeConfigAgents,
} from "../features/claude-code-agent-loader";
import type { PluginComponents } from "./plugin-components-loader";
import { reorderAgentsByPriority } from "./agent-priority-order";
import { remapAgentKeysToDisplayNames } from "./agent-key-remapper";
@@ -33,7 +40,6 @@ type AgentConfigRecord = Record<string, Record<string, unknown> | undefined> & {
function getConfiguredDefaultAgent(config: Record<string, unknown>): string | undefined {
const defaultAgent = config.default_agent;
if (typeof defaultAgent !== "string") return undefined;
const trimmedDefaultAgent = defaultAgent.trim();
return trimmedDefaultAgent.length > 0 ? trimmedDefaultAgent : undefined;
}
@@ -100,6 +106,11 @@ export async function applyAgentConfig(params: {
const opencodeProjectAgents = loadOpencodeProjectAgents(params.ctx.directory);
const rawPluginAgents = params.pluginComponents.agents;
const agentDefinitionAgents = params.pluginConfig.agent_definitions
? loadAgentDefinitions(params.pluginConfig.agent_definitions, "definition-file")
: {};
const opencodeConfigAgents = readOpencodeConfigAgents(params.ctx.directory);
const pluginAgents = Object.fromEntries(
Object.entries(rawPluginAgents).map(([key, value]) => {
if (!value) return [key, value];
@@ -118,6 +129,8 @@ export async function applyAgentConfig(params: {
...Object.entries(opencodeGlobalAgents),
...Object.entries(opencodeProjectAgents),
...Object.entries(pluginAgents).filter(([, config]) => config !== undefined),
...Object.entries(agentDefinitionAgents),
...Object.entries(opencodeConfigAgents),
]
.filter(([, config]) => config != null)
.map(([name, config]) => ({
@@ -127,6 +140,20 @@ export async function applyAgentConfig(params: {
: "",
}));
log(
"[agent-config-handler] Agent sources loaded",
{
user: Object.keys(userAgents).length,
project: Object.keys(projectAgents).length,
opencodeGlobal: Object.keys(opencodeGlobalAgents).length,
opencodeProject: Object.keys(opencodeProjectAgents).length,
plugin: Object.keys(pluginAgents).length,
agentDefinitions: Object.keys(agentDefinitionAgents).length,
opencodeConfig: Object.keys(opencodeConfigAgents).length,
config: Object.keys(configAgent ?? {}).length,
}
);
const builtinAgents = await createBuiltinAgents(
migratedDisabledAgents,
params.pluginConfig.agents,
@@ -269,6 +296,14 @@ export async function applyAgentConfig(params: {
opencodeProjectAgents,
protectedBuiltinAgentNames,
);
const filteredAgentDefinitionAgents = filterProtectedAgentOverrides(
agentDefinitionAgents,
protectedBuiltinAgentNames,
);
const filteredOpencodeConfigAgents = filterProtectedAgentOverrides(
opencodeConfigAgents,
protectedBuiltinAgentNames,
);
params.config.agent = {
...agentConfig,
@@ -283,6 +318,8 @@ export async function applyAgentConfig(params: {
...filterDisabledAgents(filteredOpencodeGlobalAgents),
...filterDisabledAgents(filteredProjectAgents),
...filterDisabledAgents(filteredOpencodeProjectAgents),
...filterDisabledAgents(filteredAgentDefinitionAgents),
...filterDisabledAgents(filteredOpencodeConfigAgents),
...filteredConfigAgents,
build: { ...migratedBuild, mode: "subagent", hidden: true },
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
@@ -311,6 +348,14 @@ export async function applyAgentConfig(params: {
opencodeProjectAgents,
protectedBuiltinAgentNames,
);
const filteredAgentDefinitionAgents = filterProtectedAgentOverrides(
agentDefinitionAgents,
protectedBuiltinAgentNames,
);
const filteredOpencodeConfigAgents = filterProtectedAgentOverrides(
opencodeConfigAgents,
protectedBuiltinAgentNames,
);
const defaultedConfigAgents = configAgent
? Object.fromEntries(
@@ -331,6 +376,8 @@ export async function applyAgentConfig(params: {
...filterDisabledAgents(filteredOpencodeGlobalAgents),
...filterDisabledAgents(filteredProjectAgents),
...filterDisabledAgents(filteredOpencodeProjectAgents),
...filterDisabledAgents(filteredAgentDefinitionAgents),
...filterDisabledAgents(filteredOpencodeConfigAgents),
...defaultedConfigAgents,
};
}