Skip to content
Merged
108 changes: 108 additions & 0 deletions aceclaw-core/src/main/java/dev/aceclaw/core/agent/SkillRegistry.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Stream;

/**
Expand All @@ -27,8 +28,11 @@ public final class SkillRegistry {

private static final String SKILLS_DIR = ".aceclaw/skills";
private static final String SKILL_FILE = "SKILL.md";
private static final int MAX_RUNTIME_SKILLS_PER_SESSION = 3;

private final Map<String, SkillConfig> registry;
private final ConcurrentHashMap<String, Map<String, SkillConfig>> runtimeRegistry =
new ConcurrentHashMap<>();

private SkillRegistry(Map<String, SkillConfig> registry) {
this.registry = Collections.unmodifiableMap(new LinkedHashMap<>(registry));
Expand Down Expand Up @@ -104,20 +108,70 @@ public Optional<SkillConfig> get(String name) {
return Optional.ofNullable(registry.get(name));
}

/**
* Looks up a skill by name with session-scoped runtime overlay support.
* Runtime skills are visible only to the session that registered them.
*/
public Optional<SkillConfig> get(String sessionId, String name) {
if (sessionId != null && !sessionId.isBlank()) {
var runtime = runtimeRegistry.get(sessionId);
if (runtime != null) {
var skill = runtime.get(name);
if (skill != null) {
return Optional.of(skill);
}
}
}
return get(name);
}

/**
* Returns all registered skill names (ordered: user skills first, then project overrides).
*/
public List<String> names() {
return List.copyOf(registry.keySet());
}

/**
* Returns all registered skill names visible to the given session.
* Session runtime skills are appended after disk-backed skills.
*/
public List<String> names(String sessionId) {
var names = new LinkedHashSet<>(registry.keySet());
if (sessionId != null && !sessionId.isBlank()) {
var runtime = runtimeRegistry.get(sessionId);
if (runtime != null && !runtime.isEmpty()) {
runtime.keySet().stream()
.sorted()
.forEach(names::add);
}
}
return List.copyOf(names);
}

/**
* Returns all registered skill configurations.
*/
public List<SkillConfig> all() {
return List.copyOf(registry.values());
}

/**
* Returns all registered skill configurations visible to the given session.
*/
public List<SkillConfig> all(String sessionId) {
var combined = new LinkedHashMap<String, SkillConfig>(registry);
if (sessionId != null && !sessionId.isBlank()) {
var runtime = runtimeRegistry.get(sessionId);
if (runtime != null && !runtime.isEmpty()) {
runtime.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> combined.put(entry.getKey(), entry.getValue()));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return List.copyOf(combined.values());
}

/**
* Returns true if the registry has no skills.
*/
Expand All @@ -135,6 +189,60 @@ public String formatDescriptions() {
var modelVisible = registry.values().stream()
.filter(s -> !s.disableModelInvocation())
.toList();
return formatDescriptions(modelVisible);
}

/**
* Formats visible skill descriptions for a given session, including runtime skills.
*/
public String formatDescriptions(String sessionId) {
var modelVisible = all(sessionId).stream()
.filter(s -> !s.disableModelInvocation())
.toList();
return formatDescriptions(modelVisible);
}

/**
* Registers a runtime skill visible only to the provided session.
*
* @return true if the skill was newly added, false if a skill with that name already existed
*/
public boolean registerRuntime(String sessionId, SkillConfig skill) {
Objects.requireNonNull(sessionId, "sessionId");
Objects.requireNonNull(skill, "skill");
if (sessionId.isBlank()) {
throw new IllegalArgumentException("sessionId must not be blank");
}
var sessionSkills = runtimeRegistry.computeIfAbsent(sessionId, ignored -> new ConcurrentHashMap<>());
synchronized (sessionSkills) {
if (!sessionSkills.containsKey(skill.name())
&& sessionSkills.size() >= MAX_RUNTIME_SKILLS_PER_SESSION) {
return false;
}
return sessionSkills.putIfAbsent(skill.name(), skill) == null;
}
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

/**
* Returns session-scoped runtime skills.
*/
public List<SkillConfig> runtimeSkills(String sessionId) {
Objects.requireNonNull(sessionId, "sessionId");
var runtime = runtimeRegistry.get(sessionId);
return runtime == null ? List.of() : List.copyOf(runtime.values());
}

/**
* Clears all runtime skills for the given session.
*/
public void clearRuntime(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
return;
}
runtimeRegistry.remove(sessionId);
}

private static String formatDescriptions(List<SkillConfig> modelVisible) {

if (modelVisible.isEmpty()) {
return "";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package dev.aceclaw.core.planner;

import dev.aceclaw.core.llm.Message;

import java.util.List;
import java.util.Objects;

Expand All @@ -8,13 +10,15 @@
*
* @param plan the final plan state (with updated step statuses)
* @param stepResults result of each step attempted (may be fewer than plan.steps() if aborted)
* @param messages all messages produced while executing the plan
* @param totalDurationMs wall-clock time for the entire plan execution
* @param success whether all attempted steps completed successfully
* @param totalTokensUsed total tokens consumed across all steps
*/
public record PlanExecutionResult(
TaskPlan plan,
List<StepResult> stepResults,
List<Message> messages,
long totalDurationMs,
boolean success,
int totalTokensUsed
Expand All @@ -23,5 +27,6 @@ public record PlanExecutionResult(
public PlanExecutionResult {
Objects.requireNonNull(plan, "plan");
stepResults = stepResults != null ? List.copyOf(stepResults) : List.of();
messages = messages != null ? List.copyOf(messages) : List.of();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ public PlanExecutionResult execute(
long planStart = System.currentTimeMillis();
var allMessages = new ArrayList<>(
conversationHistory != null ? conversationHistory : Collections.<Message>emptyList());
var generatedMessages = new ArrayList<Message>();
var stepResults = new ArrayList<StepResult>();
var mutablePlan = plan.withStatus(new PlanStatus.Executing(0, plan.steps().size()));
boolean allSuccess = true;
Expand Down Expand Up @@ -185,6 +186,7 @@ public PlanExecutionResult execute(
try {
var turn = agentLoop.runTurn(stepPrompt, allMessages, handler, cancellationToken);
allMessages.addAll(turn.newMessages());
generatedMessages.addAll(turn.newMessages());

var usage = turn.totalUsage();
var result = new StepResult(
Expand Down Expand Up @@ -241,6 +243,7 @@ public PlanExecutionResult execute(
var fallbackTurn = agentLoop.runTurn(
fallbackPrompt, allMessages, handler, cancellationToken);
allMessages.addAll(fallbackTurn.newMessages());
generatedMessages.addAll(fallbackTurn.newMessages());

var fbUsage = fallbackTurn.totalUsage();
var fallbackResult = new StepResult(
Expand Down Expand Up @@ -396,7 +399,13 @@ public PlanExecutionResult execute(
log.info("Plan execution finished: success={}, steps={}/{}, duration={}ms, tokens={}",
allSuccess, stepResults.size(), plan.steps().size(), totalDuration, totalTokens);

return new PlanExecutionResult(mutablePlan, stepResults, totalDuration, allSuccess, totalTokens);
return new PlanExecutionResult(
mutablePlan,
stepResults,
generatedMessages,
totalDuration,
allSuccess,
totalTokens);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,66 @@ void formatDescriptionsExcludesModelDisabled() throws IOException {
assertThat(descriptions).isEmpty();
}

@Test
void runtimeRegistrationIsCappedAtThreePerSession() {
var registry = SkillRegistry.empty();
for (int i = 1; i <= 3; i++) {
boolean added = registry.registerRuntime("session-a", new SkillConfig(
"runtime-" + i,
"Runtime " + i,
null,
SkillConfig.ExecutionContext.INLINE,
null,
List.of("read_file"),
4,
true,
false,
"Body " + i,
tempDir.resolve(".aceclaw/runtime-skills/runtime-" + i)));
assertThat(added).isTrue();
}

boolean fourth = registry.registerRuntime("session-a", new SkillConfig(
"runtime-4",
"Runtime 4",
null,
SkillConfig.ExecutionContext.INLINE,
null,
List.of("read_file"),
4,
true,
false,
"Body 4",
tempDir.resolve(".aceclaw/runtime-skills/runtime-4")));

assertThat(fourth).isFalse();
assertThat(registry.runtimeSkills("session-a")).hasSize(3);
}

@Test
void runtimeSkillOverridesDiskSkillInSessionDescriptions() throws IOException {
createSkill(tempDir, "review", "Disk review skill", "Disk body");
var registry = SkillRegistry.load(tempDir);
registry.registerRuntime("session-a", new SkillConfig(
"review",
"Runtime review skill",
null,
SkillConfig.ExecutionContext.INLINE,
null,
List.of("read_file"),
4,
true,
false,
"Runtime body",
tempDir.resolve(".aceclaw/runtime-skills/review")));

assertThat(registry.get("session-a", "review")).get()
.extracting(SkillConfig::description)
.isEqualTo("Runtime review skill");
assertThat(registry.formatDescriptions("session-a")).contains("Runtime review skill");
assertThat(registry.formatDescriptions("session-a")).doesNotContain("Disk review skill");
}

@Test
void missingSkillMdSkipped() throws IOException {
// Directory exists but has no SKILL.md
Expand Down Expand Up @@ -274,6 +334,34 @@ void nameFromDirectoryWhenNotInFrontmatter() throws IOException {
assertThat(config.name()).isEqualTo("my-custom-skill");
}

@Test
void runtimeSkillsAreVisibleOnlyToOwningSession() throws IOException {
createSkill(tempDir, "commit", "Commit staged changes", "Commit body.");
var registry = SkillRegistry.load(tempDir);

var runtime = new SkillConfig(
"runtime-review",
"Runtime review helper",
null,
SkillConfig.ExecutionContext.FORK,
null,
List.of("read_file", "grep"),
6,
true,
false,
"Review changed files and summarize findings.",
tempDir.resolve(".aceclaw/runtime-skills/runtime-review"));

assertThat(registry.registerRuntime("session-a", runtime)).isTrue();
assertThat(registry.names()).containsExactly("commit");
assertThat(registry.names("session-a")).containsExactly("commit", "runtime-review");
assertThat(registry.names("session-b")).containsExactly("commit");
assertThat(registry.get("session-a", "runtime-review")).contains(runtime);
assertThat(registry.get("session-b", "runtime-review")).isEmpty();
assertThat(registry.formatDescriptions("session-a")).contains("runtime-review");
assertThat(registry.formatDescriptions("session-b")).doesNotContain("runtime-review");
}

// -- Helpers --

private void createSkill(Path projectDir, String name, String description, String body)
Expand Down
Loading
Loading