conductor-oss/conductor · error · IllegalArgumentException

Circular skill reference detected

Error message

Circular skill reference detected: '${refName}' is already being normalized. Stack: ${stack}

What it means

Thrown by SkillNormalizer during cross-skill reference wiring when a skill being normalized is already on the normalization stack — i.e., it references itself directly or transitively. The normalizer uses a ThreadLocal Set (normalizingStack) to track the current normalization chain and detect cycles. The error includes the full stack so the developer can trace the circular path.

Solutions

  1. Break the circular reference: identify the cycle from the error's stack trace and remove one link.
  2. If skill A needs skill B's tools, make the dependency unidirectional (B should not reference A).
  3. Extract shared functionality into a third skill that both A and B reference without creating a cycle.
  4. For self-referencing skills, remove the skill's own name from its cross-skill references.

Example fix

// before: circular A→B→A
// skill A references skill B
// skill B references skill A
//
// after: extract shared into skill C
// skill A references skill C (no cycle)
// skill B references skill C (no cycle)
// skill C has no cross-skill references
Defensive patterns

Strategy: validation

Validate before calling

// Pre-check for cycles in the skill reference graph before normalizing
boolean hasCycle(Map<String, Set<String>> graph, String start) {
    Set<String> visited = new HashSet<>();
    Set<String> stack = new HashSet<>();
    return dfs(graph, start, visited, stack);
}

boolean dfs(Map<String, Set<String>> graph, String node,
            Set<String> visited, Set<String> stack) {
    if (stack.contains(node)) return true;  // cycle
    if (visited.contains(node)) return false;
    visited.add(node);
    stack.add(node);
    for (String dep : graph.getOrDefault(node, Set.of())) {
        if (dfs(graph, dep, visited, stack)) return true;
    }
    stack.remove(node);
    return false;
}

Type guard

static boolean isAcyclic(Map<String, Set<String>> skillGraph) {
    for (String node : skillGraph.keySet()) {
        if (hasCycle(skillGraph, node)) return false;
    }
    return true;
}

Try / catch

try {
    AgentConfig normalized = skillNormalizer.normalize(rawConfig);
} catch (IllegalArgumentException e) {
    if (e.getMessage().contains("Circular skill reference")) {
        // parse the stack from the message, find the cycle, break one link
    }
    throw e;
}

Prevention

When it happens

Trigger: A skill (framework='skill') whose cross-skill references form a cycle: skill A references skill B which references skill A, or skill A references itself directly. The normalizer detects this when refName is already in the ThreadLocal normalizingStack during Step 8 (cross-skill reference wiring).

Common situations: Two skills that reference each other for composition (A includes B, B includes A), a skill that accidentally references itself (e.g., a 'utils' skill that lists itself as a dependency), or a longer transitive chain (A→B→C→A). Common when skills are auto-generated from a dependency graph that wasn't checked for cycles.

Related errors


AI-assisted analysis of conductor-oss/conductor@cf7c3e4a8a (2026-08-14). Data as JSON: /api/errors/79d07ee35ab7303a. Report an issue: GitHub.

Appendix: source

Thrown at agentspan/src/main/java/org/conductoross/conductor/ai/agentspan/runtime/normalizer/SkillNormalizer.java:253

            log.debug(
                    "Skill '{}': created read_skill_file tool with {} resources + {} sections",
                    name,
                    resourceFiles.size(),
                    skillSections.size());
        }

        // Step 7: Build per-run workspace tools
        if (!workspaceRoots.isEmpty()) {
            addWorkspaceTools(tools, name, workspaceRoots);
        }

        // Step 8: Wire cross-skill references
        Set<String> stack = normalizingStack.get();
        for (Map.Entry<String, Object> entry : crossSkillRefs.entrySet()) {
            String refName = entry.getKey();
            if (stack.contains(refName)) {
                throw new IllegalArgumentException(
                        "Circular skill reference detected: '"
                                + refName
                                + "' is already being normalized. Stack: "
                                + stack);
            }
            stack.add(refName);
            try {
                Map<String, Object> refConfig = (Map<String, Object>) entry.getValue();
                if (!workspaceRoots.isEmpty() && !refConfig.containsKey("workspace")) {
                    refConfig = new LinkedHashMap<>(refConfig);
                    refConfig.put("workspace", workspace);
                }
                AgentConfig refAgent = this.normalize(refConfig);

                Map<String, Object> refToolConfig = new LinkedHashMap<>();
                refToolConfig.put("agentConfig", refAgent);

                // inputSchema must include "request" property — same as sub-agent

View on GitHub (pinned to cf7c3e4a8a)