fix(agents): use config data instead of client API to avoid init deadlock (#1623)
This commit is contained in:
parent
f035be842d
commit
321b319b58
@ -250,7 +250,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
expect(agents.sisyphus.prompt).toContain("git-master")
|
||||
})
|
||||
|
||||
test("includes custom agents from OpenCode registry in orchestrator prompts", async () => {
|
||||
test("includes custom agents in orchestrator prompts when provided via config", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set([
|
||||
@ -263,20 +263,13 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
])
|
||||
)
|
||||
|
||||
const client = {
|
||||
agent: {
|
||||
list: async () => ({
|
||||
data: [
|
||||
{
|
||||
name: "researcher",
|
||||
description: "Research agent for deep analysis",
|
||||
mode: "subagent",
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
const customAgentSummaries = [
|
||||
{
|
||||
name: "researcher",
|
||||
description: "Research agent for deep analysis",
|
||||
hidden: false,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
try {
|
||||
// #when
|
||||
@ -288,7 +281,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
client
|
||||
customAgentSummaries
|
||||
)
|
||||
|
||||
// #then
|
||||
@ -299,6 +292,179 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("excludes hidden custom agents from orchestrator prompts", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
{
|
||||
name: "hidden-agent",
|
||||
description: "Should never show",
|
||||
hidden: true,
|
||||
},
|
||||
]
|
||||
|
||||
try {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents(
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
TEST_DEFAULT_MODEL,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
customAgentSummaries
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus.prompt).not.toContain("hidden-agent")
|
||||
expect(agents.hephaestus.prompt).not.toContain("hidden-agent")
|
||||
expect(agents.atlas.prompt).not.toContain("hidden-agent")
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("excludes disabled custom agents from orchestrator prompts", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
{
|
||||
name: "disabled-agent",
|
||||
description: "Should never show",
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
try {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents(
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
TEST_DEFAULT_MODEL,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
customAgentSummaries
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus.prompt).not.toContain("disabled-agent")
|
||||
expect(agents.hephaestus.prompt).not.toContain("disabled-agent")
|
||||
expect(agents.atlas.prompt).not.toContain("disabled-agent")
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("excludes custom agents when disabledAgents contains their name (case-insensitive)", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"])
|
||||
)
|
||||
|
||||
const disabledAgents = ["ReSeArChEr"]
|
||||
const customAgentSummaries = [
|
||||
{
|
||||
name: "researcher",
|
||||
description: "Should never show",
|
||||
},
|
||||
]
|
||||
|
||||
try {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents(
|
||||
disabledAgents,
|
||||
{},
|
||||
undefined,
|
||||
TEST_DEFAULT_MODEL,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
customAgentSummaries
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus.prompt).not.toContain("researcher")
|
||||
expect(agents.hephaestus.prompt).not.toContain("researcher")
|
||||
expect(agents.atlas.prompt).not.toContain("researcher")
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("deduplicates custom agents case-insensitively", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
{ name: "Researcher", description: "First" },
|
||||
{ name: "researcher", description: "Second" },
|
||||
]
|
||||
|
||||
try {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents(
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
TEST_DEFAULT_MODEL,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
customAgentSummaries
|
||||
)
|
||||
|
||||
// #then
|
||||
const matches = agents.sisyphus.prompt.match(/Custom agent: researcher/gi) ?? []
|
||||
expect(matches.length).toBe(1)
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("sanitizes custom agent strings for markdown tables", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"])
|
||||
)
|
||||
|
||||
const customAgentSummaries = [
|
||||
{
|
||||
name: "table-agent",
|
||||
description: "Line1\nAlpha | Beta",
|
||||
},
|
||||
]
|
||||
|
||||
try {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents(
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
TEST_DEFAULT_MODEL,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
customAgentSummaries
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus.prompt).toContain("Line1 Alpha \\| Beta")
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||
|
||||
@ -68,6 +68,14 @@ type RegisteredAgentSummary = {
|
||||
description: string
|
||||
}
|
||||
|
||||
function sanitizeMarkdownTableCell(value: string): string {
|
||||
return value
|
||||
.replace(/\r?\n/g, " ")
|
||||
.replace(/\|/g, "\\|")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
@ -85,37 +93,28 @@ function parseRegisteredAgentSummaries(input: unknown): RegisteredAgentSummary[]
|
||||
const hidden = item.hidden
|
||||
if (hidden === true) continue
|
||||
|
||||
const disabled = item.disabled
|
||||
if (disabled === true) continue
|
||||
|
||||
const enabled = item.enabled
|
||||
if (enabled === false) continue
|
||||
|
||||
const description = typeof item.description === "string" ? item.description : ""
|
||||
result.push({ name, description })
|
||||
result.push({ name, description: sanitizeMarkdownTableCell(description) })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function fetchRegisteredAgentsFromClient(client: unknown): Promise<RegisteredAgentSummary[]> {
|
||||
if (!isRecord(client)) return []
|
||||
const agentObj = client.agent
|
||||
if (!isRecord(agentObj)) return []
|
||||
const listFn = agentObj.list
|
||||
if (typeof listFn !== "function") return []
|
||||
|
||||
try {
|
||||
const response = await listFn.call(agentObj)
|
||||
if (!isRecord(response)) return []
|
||||
return parseRegisteredAgentSummaries(response.data)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function buildCustomAgentMetadata(agentName: string, description: string): AgentPromptMetadata {
|
||||
const shortDescription = truncateDescription(description).trim()
|
||||
const shortDescription = sanitizeMarkdownTableCell(truncateDescription(description))
|
||||
const safeAgentName = sanitizeMarkdownTableCell(agentName)
|
||||
return {
|
||||
category: "specialist",
|
||||
cost: "CHEAP",
|
||||
triggers: [
|
||||
{
|
||||
domain: `Custom agent: ${agentName}`,
|
||||
domain: `Custom agent: ${safeAgentName}`,
|
||||
trigger: shortDescription || "Use when this agent's description matches the task",
|
||||
},
|
||||
],
|
||||
@ -303,13 +302,13 @@ export async function createBuiltinAgents(
|
||||
categories?: CategoriesConfig,
|
||||
gitMasterConfig?: GitMasterConfig,
|
||||
discoveredSkills: LoadedSkill[] = [],
|
||||
client?: any,
|
||||
customAgentSummaries?: unknown,
|
||||
browserProvider?: BrowserAutomationProvider,
|
||||
uiSelectedModel?: string,
|
||||
disabledSkills?: Set<string>
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
// IMPORTANT: Do NOT pass client to fetchAvailableModels during plugin initialization.
|
||||
// IMPORTANT: Do NOT call OpenCode client APIs during plugin initialization.
|
||||
// This function is called from config handler, and calling client API causes deadlock.
|
||||
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301
|
||||
const availableModels = await fetchAvailableModels(undefined, {
|
||||
@ -349,7 +348,7 @@ export async function createBuiltinAgents(
|
||||
|
||||
const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable]
|
||||
|
||||
const registeredAgents = await fetchRegisteredAgentsFromClient(client)
|
||||
const registeredAgents = parseRegisteredAgentSummaries(customAgentSummaries)
|
||||
const builtinAgentNames = new Set(Object.keys(agentSources).map((n) => n.toLowerCase()))
|
||||
const disabledAgentNames = new Set(disabledAgents.map((n) => n.toLowerCase()))
|
||||
|
||||
|
||||
@ -183,19 +183,40 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
// Pass it as uiSelectedModel so it takes highest priority in model resolution
|
||||
const currentModel = config.model as string | undefined;
|
||||
const disabledSkills = new Set<string>(pluginConfig.disabled_skills ?? []);
|
||||
const builtinAgents = await createBuiltinAgents(
|
||||
migratedDisabledAgents,
|
||||
pluginConfig.agents,
|
||||
ctx.directory,
|
||||
undefined, // systemDefaultModel - let fallback chain handle this
|
||||
pluginConfig.categories,
|
||||
pluginConfig.git_master,
|
||||
allDiscoveredSkills,
|
||||
ctx.client,
|
||||
browserProvider,
|
||||
currentModel, // uiSelectedModel - takes highest priority
|
||||
disabledSkills
|
||||
);
|
||||
|
||||
type AgentConfig = Record<
|
||||
string,
|
||||
Record<string, unknown> | undefined
|
||||
> & {
|
||||
build?: Record<string, unknown>;
|
||||
plan?: Record<string, unknown>;
|
||||
explore?: { tools?: Record<string, unknown> };
|
||||
librarian?: { tools?: Record<string, unknown> };
|
||||
"multimodal-looker"?: { tools?: Record<string, unknown> };
|
||||
atlas?: { tools?: Record<string, unknown> };
|
||||
sisyphus?: { tools?: Record<string, unknown> };
|
||||
};
|
||||
const configAgent = config.agent as AgentConfig | undefined;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function buildCustomAgentSummaryInput(agents: Record<string, unknown> | undefined): unknown[] {
|
||||
if (!agents) return [];
|
||||
|
||||
const result: unknown[] = [];
|
||||
for (const [name, value] of Object.entries(agents)) {
|
||||
if (!isRecord(value)) continue;
|
||||
|
||||
const description = typeof value.description === "string" ? value.description : "";
|
||||
const hidden = value.hidden === true;
|
||||
const disabled = value.disabled === true || value.enabled === false;
|
||||
result.push({ name, description, hidden, disabled });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Claude Code agents: Do NOT apply permission migration
|
||||
// Claude Code uses whitelist-based tools format which is semantically different
|
||||
@ -216,6 +237,27 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
])
|
||||
);
|
||||
|
||||
const customAgentSummaries = [
|
||||
...buildCustomAgentSummaryInput(configAgent),
|
||||
...buildCustomAgentSummaryInput(userAgents),
|
||||
...buildCustomAgentSummaryInput(projectAgents),
|
||||
...buildCustomAgentSummaryInput(pluginAgents),
|
||||
];
|
||||
|
||||
const builtinAgents = await createBuiltinAgents(
|
||||
migratedDisabledAgents,
|
||||
pluginConfig.agents,
|
||||
ctx.directory,
|
||||
undefined, // systemDefaultModel - let fallback chain handle this
|
||||
pluginConfig.categories,
|
||||
pluginConfig.git_master,
|
||||
allDiscoveredSkills,
|
||||
customAgentSummaries,
|
||||
browserProvider,
|
||||
currentModel, // uiSelectedModel - takes highest priority
|
||||
disabledSkills
|
||||
);
|
||||
|
||||
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
|
||||
const builderEnabled =
|
||||
pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
||||
@ -224,20 +266,6 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
|
||||
const shouldDemotePlan = plannerEnabled && replacePlan;
|
||||
|
||||
type AgentConfig = Record<
|
||||
string,
|
||||
Record<string, unknown> | undefined
|
||||
> & {
|
||||
build?: Record<string, unknown>;
|
||||
plan?: Record<string, unknown>;
|
||||
explore?: { tools?: Record<string, unknown> };
|
||||
librarian?: { tools?: Record<string, unknown> };
|
||||
"multimodal-looker"?: { tools?: Record<string, unknown> };
|
||||
atlas?: { tools?: Record<string, unknown> };
|
||||
sisyphus?: { tools?: Record<string, unknown> };
|
||||
};
|
||||
const configAgent = config.agent as AgentConfig | undefined;
|
||||
|
||||
if (isSisyphusEnabled && builtinAgents.sisyphus) {
|
||||
(config as { default_agent?: string }).default_agent = "sisyphus";
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user