conductor-oss/conductor · error · IllegalArgumentException

Circular skill reference detected: {key}

Error message

Circular skill reference detected: {key}

What it means

Thrown by rawConfigForDetail when hydrating cross-skill references would recurse into a skill already on the current resolution stack — i.e. skill A's crossSkillRefs reference B, and B's reference A (directly or transitively). A `Set<String>` of `name@version` keys is threaded through the recursion; a duplicate add returns false and aborts. This prevents infinite recursion / stack overflow when building a SkillDetail tree.

Source

Thrown at agentspan/src/main/java/org/conductoross/conductor/ai/agentspan/runtime/service/SkillRegistryService.java:690

    private Map<String, Object> mergeMetadata(
            Map<String, Object> packageMetadata, Map<String, Object> manifestMetadata) {
        if (packageMetadata.isEmpty()) {
            return manifestMetadata;
        }
        if (manifestMetadata.isEmpty()) {
            return packageMetadata;
        }
        Map<String, Object> merged = new LinkedHashMap<>(packageMetadata);
        merged.putAll(manifestMetadata);
        return merged;
    }

    @SuppressWarnings("unchecked")
    private Map<String, Object> rawConfigForDetail(SkillDetail detail, Set<String> stack) {
        String key = detail.getName() + "@" + detail.getVersion();
        if (!stack.add(key)) {
            throw new IllegalArgumentException("Circular skill reference detected: " + key);
        }
        try {
            Map<String, Object> rawConfig = deepCopy(detail.getRawConfig());
            rawConfig.put(
                    "skillRef",
                    Map.of(
                            "name", detail.getName(),
                            "version", detail.getVersion(),
                            "checksum", detail.getChecksum()));
            Map<String, Object> pinnedRefs = toMap(rawConfig.get("crossSkillRefs"));
            if (!pinnedRefs.isEmpty()) {
                rawConfig.put("crossSkillRefs", hydratePinnedCrossSkills(pinnedRefs, stack));
            } else if (!Boolean.TRUE.equals(rawConfig.get("crossSkillRefsPinned"))) {
                Object skillMd = rawConfig.get("skillMd");
                if (skillMd instanceof String md) {
                    rawConfig.put("crossSkillRefs", resolveRegisteredCrossSkills(md, stack));
                }
            }

View on GitHub (pinned to cf7c3e4a8a)

Solutions

  1. Inspect the `name@version` in the message — that is the skill re-entering its own resolution chain.
  2. Open each manifest's `crossSkillRefs` and break the cycle: make the dependency one-directional, or move shared logic into a third skill both reference.
  3. Re-publish the corrected skill(s); the registry does not auto-heal cycles.

Example fix

// before: skill-a references skill-b, skill-b references skill-a
crossSkillRefs:
  skill-b: "1.0.0"   # in skill-a manifest
# in skill-b manifest
crossSkillRefs:
  skill-a: "1.0.0"   # REMOVE — forms cycle
// after: only skill-a references skill-b; skill-b references a shared helper
crossSkillRefs:
  skill-helpers: "1.0.0"
Defensive patterns

Strategy: validation

Validate before calling

// Detect cross-skill reference cycles before publishing.
boolean hasCycle(Map<String, Set<String>> graph, String start) {
    Set<String> visited = new HashSet<>(), stack = new HashSet<>();
    java.util.function.BiPredicate<String, String> dfs = null;
    dfs = (node, from) -> {
        if (stack.contains(node)) return true;
        if (!visited.add(node)) return false;
        stack.add(node);
        for (String next : graph.getOrDefault(node, Set.of())) if (dfs.test(next, node)) return true;
        stack.remove(node);
        return false;
    };
    return dfs.test(start, start);
}

Type guard

// Build a name@version -> set of referenced name@version map from manifests, then run hasCycle above before publish.

Try / catch

try {
    return skillRegistry.getDetail(name, version);
} catch (IllegalArgumentException e) {
    if (e.getMessage().contains("Circular skill reference")) {
        return conflict(e.getMessage()); // 409, author must break the cycle
    }
    throw e;
}

Prevention

When it happens

Trigger: Two or more skill manifests declare pinned crossSkillRefs that form a cycle: A->B->A, or A->B->C->A. Calling any API that materializes a SkillDetail with hydrated refs (list-with-details, get detail) walks the graph and trips the guard.

Common situations: Author copy-pasted a manifest and forgot to update the ref list; two skills legitimately co-operate and were published referencing each other; a ref was bumped but the cycle was not noticed because individual publishes succeed.

Related errors


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