refactor: use case-insensitive matching for agent names

Apply case-insensitive utilities across agents, delegation, and tool
restrictions. Removes duplicate implementations from agents/utils.ts.
Agent validation now normalizes input to canonical names.
This commit is contained in:
justsisyphus
2026-01-23 00:55:01 +09:00
parent febc32d7f4
commit 45b2782d55
5 changed files with 30 additions and 32 deletions

View File

@@ -10,7 +10,7 @@ import { createMetisAgent } from "./metis"
import { createAtlasAgent } from "./atlas"
import { createMomusAgent } from "./momus"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS } from "../shared"
import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS, findCaseInsensitive, includesCaseInsensitive } from "../shared"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
import { createBuiltinSkills } from "../features/builtin-skills"
@@ -46,22 +46,6 @@ function isFactory(source: AgentSource): source is AgentFactory {
return typeof source === "function"
}
function findCaseInsensitive<T>(obj: Record<string, T> | undefined, key: string): T | undefined {
if (!obj) return undefined
const exactMatch = obj[key]
if (exactMatch !== undefined) return exactMatch
const lowerKey = key.toLowerCase()
for (const [k, v] of Object.entries(obj)) {
if (k.toLowerCase() === lowerKey) return v
}
return undefined
}
function includesCaseInsensitive(arr: string[], value: string): boolean {
const lowerValue = value.toLowerCase()
return arr.some((item) => item.toLowerCase() === lowerValue)
}
export function buildAgent(
source: AgentSource,
model: string,

View File

@@ -73,7 +73,7 @@ import { BackgroundManager } from "./features/background-agent";
import { SkillMcpManager } from "./features/skill-mcp-manager";
import { initTaskToastManager } from "./features/task-toast-manager";
import { type HookName } from "./config";
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor } from "./shared";
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive } from "./shared";
import { loadPluginConfig } from "./plugin-config";
import { createModelCacheState, getModelLimit } from "./plugin-state";
import { createConfigHandler } from "./plugin-handlers";
@@ -489,8 +489,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
if (input.tool === "task") {
const args = output.args as Record<string, unknown>;
const subagentType = args.subagent_type as string;
const isExploreOrLibrarian = ["explore", "librarian"].includes(
subagentType
const isExploreOrLibrarian = includesCaseInsensitive(
["explore", "librarian"],
subagentType ?? ""
);
args.tools = {

View File

@@ -4,6 +4,8 @@
* true = tool allowed, false = tool denied.
*/
import { findCaseInsensitive } from "./case-insensitive"
const EXPLORATION_AGENT_DENYLIST: Record<string, boolean> = {
write: false,
edit: false,
@@ -35,10 +37,10 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
}
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
return AGENT_RESTRICTIONS[agentName] ?? {}
return findCaseInsensitive(AGENT_RESTRICTIONS, agentName) ?? {}
}
export function hasAgentToolRestrictions(agentName: string): boolean {
const restrictions = AGENT_RESTRICTIONS[agentName]
const restrictions = findCaseInsensitive(AGENT_RESTRICTIONS, agentName)
return restrictions !== undefined && Object.keys(restrictions).length > 0
}

View File

@@ -4,7 +4,7 @@ import { join } from "node:path"
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
import type { CallOmoAgentArgs } from "./types"
import type { BackgroundManager } from "../../features/background-agent"
import { log, getAgentToolRestrictions } from "../../shared"
import { log, getAgentToolRestrictions, includesCaseInsensitive } from "../../shared"
import { consumeNewMessages } from "../../shared/session-cursor"
import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
@@ -46,7 +46,7 @@ export function createCallOmoAgent(
description: tool.schema.string().describe("A short (3-5 words) description of the task"),
prompt: tool.schema.string().describe("The task for the agent to perform"),
subagent_type: tool.schema
.enum(ALLOWED_AGENTS)
.string()
.describe("The type of specialized agent to use for this task (explore or librarian only)"),
run_in_background: tool.schema
.boolean()
@@ -57,9 +57,13 @@ export function createCallOmoAgent(
const toolCtx = toolContext as ToolContextWithMetadata
log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`)
if (!ALLOWED_AGENTS.includes(args.subagent_type as typeof ALLOWED_AGENTS[number])) {
// Case-insensitive agent validation - allows "Explore", "EXPLORE", "explore" etc.
if (!includesCaseInsensitive([...ALLOWED_AGENTS], args.subagent_type)) {
return `Error: Invalid agent type "${args.subagent_type}". Only ${ALLOWED_AGENTS.join(", ")} are allowed.`
}
const normalizedAgent = args.subagent_type.toLowerCase() as typeof ALLOWED_AGENTS[number]
args = { ...args, subagent_type: normalizedAgent }
if (args.run_in_background) {
if (args.session_id) {

View File

@@ -11,7 +11,7 @@ import { discoverSkills } from "../../features/opencode-skill-loader"
import { getTaskToastManager } from "../../features/task-toast-manager"
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state"
import { log, getAgentToolRestrictions, resolveModel, getOpenCodeConfigPaths } from "../../shared"
import { log, getAgentToolRestrictions, resolveModel, getOpenCodeConfigPaths, findByNameCaseInsensitive, equalsIgnoreCase } from "../../shared"
import { fetchAvailableModels } from "../../shared/model-availability"
import { resolveModelWithFallback } from "../../shared/model-resolver"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
@@ -716,7 +716,7 @@ To resume this session: resume="${sessionID}"`
}
const agentName = args.subagent_type.trim()
if (agentName === SISYPHUS_JUNIOR_AGENT) {
if (equalsIgnoreCase(agentName, SISYPHUS_JUNIOR_AGENT)) {
return `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. Use category parameter instead (e.g., ${categoryExamples}).
Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.`
@@ -725,25 +725,32 @@ Sisyphus-Junior is spawned automatically when you specify a category. Pick the a
agentToUse = agentName
// Validate agent exists and is callable (not a primary agent)
// Uses case-insensitive matching to allow "Oracle", "oracle", "ORACLE" etc.
try {
const agentsResult = await client.app.agents()
type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all" }
const agents = (agentsResult as { data?: AgentInfo[] }).data ?? agentsResult as unknown as AgentInfo[]
const callableAgents = agents.filter((a) => a.mode !== "primary")
const callableNames = callableAgents.map((a) => a.name)
if (!callableNames.includes(agentToUse)) {
const isPrimaryAgent = agents.some((a) => a.name === agentToUse && a.mode === "primary")
const matchedAgent = findByNameCaseInsensitive(callableAgents, agentToUse)
if (!matchedAgent) {
const isPrimaryAgent = findByNameCaseInsensitive(
agents.filter((a) => a.mode === "primary"),
agentToUse
)
if (isPrimaryAgent) {
return `Cannot call primary agent "${agentToUse}" via delegate_task. Primary agents are top-level orchestrators.`
return `Cannot call primary agent "${isPrimaryAgent.name}" via delegate_task. Primary agents are top-level orchestrators.`
}
const availableAgents = callableNames
const availableAgents = callableAgents
.map((a) => a.name)
.sort()
.join(", ")
return `Unknown agent: "${agentToUse}". Available agents: ${availableAgents}`
}
// Use the canonical agent name from registration
agentToUse = matchedAgent.name
} catch {
// If we can't fetch agents, proceed anyway - the session.prompt will fail with a clearer error
}