Skip to content
Merged
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 @@ -29,6 +30,8 @@ public final class SkillRegistry {
private static final String SKILL_FILE = "SKILL.md";

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 +107,66 @@ 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()) {
names.addAll(runtime.keySet());
}
}
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.forEach(combined::putIfAbsent);
}
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 +184,54 @@ 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<>());
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
Expand Up @@ -274,6 +274,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
27 changes: 23 additions & 4 deletions aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawDaemon.java
Original file line number Diff line number Diff line change
Expand Up @@ -340,13 +340,16 @@ private void wireAgentHandler(Path workingDir) {

// Skill system (project + user skills from .aceclaw/skills/)
var skillRegistry = SkillRegistry.load(workingDir);
if (!skillRegistry.isEmpty()) {
DynamicSkillGenerator dynamicSkillGenerator = null;
{
var contentResolver = new SkillContentResolver(workingDir);
var skillTool = new SkillTool(skillRegistry, contentResolver, subAgentRunner);
toolRegistry.register(skillTool);
if (!skillRegistry.isEmpty()) {
log.info("Skills registered: {}", skillRegistry.names());
} else {
log.debug("No skills found, SkillTool not registered");
} else {
log.debug("No disk-backed skills found, SkillTool registered for runtime skills only");
}
}

// 5. System prompt (with 8-tier memory hierarchy + daily journal + model identity + budget)
Expand Down Expand Up @@ -410,7 +413,7 @@ private void wireAgentHandler(Path workingDir) {
promptBudget,
toolNames,
config.braveSearchApiKey() != null,
skillDescriptions);
skillRegistry::formatDescriptions);
agentHandler.setAdaptiveContinuationConfig(
config.adaptiveContinuationEnabled(),
config.adaptiveContinuationMaxSegments(),
Expand Down Expand Up @@ -544,6 +547,11 @@ private void wireAgentHandler(Path workingDir) {
}
} : null);
agentHandler.setSelfImprovementEngine(selfImprovementEngine);
dynamicSkillGenerator = new DynamicSkillGenerator(
llmClient,
agentHandler::getModelForSession,
skillRegistry);
agentHandler.setDynamicSkillGenerator(dynamicSkillGenerator);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
candidateStoreRef = cs;

// Pass candidate store to agent handler for prompt injection
Expand Down Expand Up @@ -649,6 +657,7 @@ private void wireAgentHandler(Path workingDir) {
scope.workspaceHash(),
scope.workingDir())
);
final var runtimeSkillGeneratorForSessionEnd = dynamicSkillGenerator;
sessionManager.setSessionEndCallback(session -> {
var sessionWorkingDir = session.projectPath().toAbsolutePath().normalize();
var sessionWorkspaceHash = WorkspacePaths.workspaceHash(sessionWorkingDir);
Expand Down Expand Up @@ -721,6 +730,16 @@ private void wireAgentHandler(Path workingDir) {
log.warn("Learning maintenance trigger failed: {}", e.getMessage());
}
}
if (runtimeSkillGeneratorForSessionEnd != null) {
try {
int persistedDrafts = runtimeSkillGeneratorForSessionEnd.persistDrafts(session.id(), sessionWorkingDir);
if (persistedDrafts > 0) {
log.info("Persisted {} runtime skill drafts for session {}", persistedDrafts, session.id());
}
} catch (Exception e) {
log.warn("Runtime skill draft persistence failed: {}", e.getMessage());
}
}

// Clean up session-scoped resources in the agent handler
agentHandlerForCleanup.clearSessionOverride(session.id());
Expand Down
Loading
Loading