Add agent-skills module: SKILL.md folders loaded by the Skill tool

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Lm655hdgDo5xsSLNomgdsy
This commit is contained in:
2026-10-13 02:48:07 +00:00
co-authored by Claude Sonnet 5.5
parent e79b75a105
commit c13195295d
21 changed files with 665 additions and 0 deletions
@@ -0,0 +1,40 @@
package com.ankurm.agentskills;
import java.util.stream.IntStream;
import com.ankurm.agentskills.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springaicommunity.agent.tools.SkillsTool;
import org.springframework.ai.tool.ToolCallback;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The reason skills exist: the listing grows with the number of skills, not with their size.
* Sizes are characters; tokens are roughly characters / 4 for English prose, which is only an estimate.
*/
class ListingCostTest {
private static final String BODY = "Step one. Step two. Step three. ".repeat(250); // ~8,000 characters of instructions
@Test
void listingCostFollowsDescriptionsNotBodies() {
try (Transcript t = new Transcript("04-listing-cost.txt", "Characters in the tool description as skills are added")) {
t.line("%-8s %-22s %-24s %s", "skills", "tool description chars", "bodies, if all inlined", "ratio");
for (int n : new int[] { 3, 10, 25, 50, 100 }) {
SkillsTool.Builder b = SkillsTool.builder();
IntStream.range(0, n).forEach(i -> b.addSkill("skill-" + i,
"Handle the recurring task number " + i + " in the usual way. Use when asked about task " + i + ".", BODY));
ToolCallback tool = b.build();
int listing = tool.getToolDefinition().description().length();
int bodies = n * BODY.length();
t.line("%-8d %-22d %-24d %.1fx smaller", n, listing, bodies, (double) bodies / listing);
if (n == 100) {
assertThat(listing).isLessThan(bodies / 10);
}
}
t.blank();
t.line("each skill body used here is %d characters; each description about 90.", BODY.length());
}
}
}
@@ -0,0 +1,34 @@
package com.ankurm.agentskills;
import com.ankurm.agentskills.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import static org.assertj.core.api.Assertions.assertThat;
/** What the model is given before it has chosen any skill: one tool whose description lists every skill. */
class SkillListingTest {
@Test
void theModelSeesNamesAndDescriptionsButNoBodies() {
ToolCallback tool = SkillClients.bundledSkills();
String description = tool.getToolDefinition().description();
try (Transcript t = new Transcript("01-skill-listing.txt", "What the model sees before it loads any skill")) {
t.line("tool name : %s", tool.getToolDefinition().name());
t.line("input schema : %s", tool.getToolDefinition().inputSchema());
t.line("description size : %d characters", description.length());
t.blank();
t.line("--- the <available_skills> block of the tool description ---");
String block = description.substring(description.lastIndexOf("<available_skills>"));
t.line("%s", block);
t.blank();
t.line("contains 'release-notes' name : %s", description.contains("release-notes"));
t.line("contains a body line ('Breaking') : %s", description.contains("Breaking"));
t.line("contains the reference file name : %s", description.contains("style-guide"));
}
assertThat(description).contains("release-notes", "sql-review", "incident-summary");
assertThat(description).doesNotContain("Breaking").doesNotContain("style-guide");
}
}
@@ -0,0 +1,63 @@
package com.ankurm.agentskills;
import java.util.List;
import com.ankurm.agentskills.support.ScriptedChatModel;
import com.ankurm.agentskills.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.prompt.Prompt;
import static org.assertj.core.api.Assertions.assertThat;
/** The model asks for one skill by name; the full SKILL.md comes back as the tool result, in the second model call. */
class SkillLoadRoundTripTest {
@Test
void loadingASkillPutsItsBodyInTheSecondCall() {
ScriptedChatModel model = ScriptedChatModel.builder()
.thenCallTools(new AssistantMessage.ToolCall("call-1", "function", "Skill", "{\"command\":\"release-notes\"}"))
.thenRespond("## Breaking\n- Rename `cache.ttl` to `cache.expiry`.")
.build();
ChatClient client = SkillClients.client(model, List.of(SkillClients.bundledSkills()));
String answer = client.prompt().user("Draft release notes for 2.4.0 from these changes: ...").call().content();
Prompt first = model.capturedPrompts().get(0);
Prompt second = model.capturedPrompts().get(1);
String toolResult = second.getInstructions().stream()
.filter(ToolResponseMessage.class::isInstance)
.map(ToolResponseMessage.class::cast)
.flatMap(m -> m.getResponses().stream())
.map(ToolResponseMessage.ToolResponse::responseData)
.findFirst()
.orElseThrow();
try (Transcript t = new Transcript("02-skill-load-round-trip.txt", "Loading one skill: two model calls")) {
t.line("model calls: %d", model.callCount());
t.line("first call mentions the skill body ('Breaking' in any message) : %s",
first.getInstructions().stream().anyMatch(m -> m.getText() != null && m.getText().contains("Breaking")));
t.blank();
t.line("--- tool result fed back to the model (what the Skill tool returned) ---");
t.line("%s", readable(toolResult));
t.blank();
t.line("--- final answer ---");
t.line("%s", answer);
}
assertThat(model.callCount()).isEqualTo(2);
assertThat(toolResult).contains("Base directory").contains("Breaking").contains("references/style-guide.md");
}
/** The tool result is a JSON string literal; show it as the model reads it, minus this machine's absolute path. */
private static String readable(String jsonString) {
String s = jsonString.strip();
if (s.startsWith("\"") && s.endsWith("\"")) {
s = s.substring(1, s.length() - 1);
}
s = s.replace("\\n", "\n").replace("\\\"", "\"").replace("\\\\", "\\");
return s.replaceAll("(?m)^(Base directory for this skill: ).*?(/target/classes/)", "$1<module>$2");
}
}
@@ -0,0 +1,64 @@
package com.ankurm.agentskills;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import com.ankurm.agentskills.support.Transcript;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springaicommunity.agent.tools.SkillsTool;
import org.springaicommunity.agent.utils.Skills;
import org.springframework.ai.tool.ToolCallback;
/** What the loader does with an unknown name, a duplicate name and a SKILL.md with no description. */
class SkillLoadingEdgeCasesTest {
@TempDir
Path dir;
@Test
void recordWhatActuallyHappens() throws Exception {
Path dup = Files.createDirectories(dir.resolve("copy/release-notes"));
Files.writeString(dup.resolve("SKILL.md"),
"---\nname: release-notes\ndescription: A second skill that reuses an existing name.\n---\n\nSECOND COPY BODY\n");
Path nodesc = Files.createDirectories(dir.resolve("copy/no-description"));
Files.writeString(nodesc.resolve("SKILL.md"), "---\nname: no-description\n---\n\nBODY WITHOUT A DESCRIPTION\n");
try (Transcript t = new Transcript("03-loading-edge-cases.txt", "Unknown, duplicate and description-less skills")) {
ToolCallback bundled = SkillClients.bundledSkills();
t.line("== unknown skill name");
t.line("call Skill(command=\"does-not-exist\") returns: %s", oneLine(call(bundled, "does-not-exist")));
t.blank();
t.line("== the same name in two directories (classpath skills first, then the copy)");
ToolCallback both = SkillsTool.builder()
.addSkillsResource(new org.springframework.core.io.ClassPathResource("skills"))
.addSkillsDirectory(dir.resolve("copy").toString())
.build();
String loaded = call(both, "release-notes");
t.line("body served for release-notes contains the first copy ('Breaking') : %s", loaded.contains("Breaking"));
t.line("body served for release-notes contains the second copy : %s", loaded.contains("SECOND COPY BODY"));
t.blank();
t.line("== a SKILL.md with no description");
List<SkillsTool.Skill> skills = Skills.loadDirectory(dir.resolve("copy").toString());
skills.forEach(s -> t.line("loaded: name=%s frontMatterKeys=%s", s.name(), s.frontMatter().keySet()));
String listing = both.getToolDefinition().description();
t.line("'no-description' appears in the tool description : %s", listing.contains("no-description"));
t.blank();
t.line("== the allowed-tools key in sql-review's frontmatter");
t.line("echoed into the listing as <allowed-tools> : %s", bundled.getToolDefinition().description().contains("<allowed-tools>Bash</allowed-tools>"));
}
}
private static String call(ToolCallback tool, String command) {
return tool.call("{\"command\":\"" + command + "\"}");
}
private static String oneLine(String s) {
return s.replace("\n", "\\n");
}
}
@@ -0,0 +1,116 @@
package com.ankurm.agentskills.support;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* A hand-written {@link ChatModel} that returns a pre-programmed queue of {@link ChatResponse}s,
* one per {@link #call(Prompt)} invocation, instead of calling a real LLM API. {@code ChatModel}
* has exactly one abstract method -- confirmed with {@code javap} against
* {@code spring-ai-model-2.0.1.jar}, everything else on the interface has a default implementation
* -- so this is the entire surface a deterministic test double needs to implement.
*
* <p>ToolCallingAdvisor drive the tool-calling loop by calling the
* underlying {@link ChatModel} once per round: once to get the model's first response (which may
* contain tool calls), then once more per round of tool results fed back in, until a response
* comes back with no tool calls. Queuing responses here lets a test assert the exact shape of that
* loop -- how many rounds it took, what tool calls appeared, what the final answer was -- without
* an API key, network access, or the nondeterminism of an actual model.
*
* <p>Every {@link Prompt} the advisor sends is recorded in {@link #capturedPrompts()} so a test can
* also assert on what the advisor sent back on the next round -- in particular, that a
* {@code ToolResponseMessage} was appended after tool execution.
*/
public class ScriptedChatModel implements ChatModel {
private final Queue<ChatResponse> script;
private final List<Prompt> capturedPrompts = new CopyOnWriteArrayList<>();
private ScriptedChatModel(Deque<ChatResponse> script) {
this.script = script;
}
public static Builder builder() {
return new Builder();
}
@Override
public ChatResponse call(Prompt prompt) {
this.capturedPrompts.add(prompt);
ChatResponse next = this.script.poll();
if (next == null) {
throw new IllegalStateException(
"ScriptedChatModel ran out of queued responses after " + this.capturedPrompts.size()
+ " calls. Prompts so far: " + this.capturedPrompts);
}
return next;
}
public List<Prompt> capturedPrompts() {
return List.copyOf(this.capturedPrompts);
}
/**
* {@code DefaultChatClientUtils} builds every outgoing {@link Prompt}'s options from
* {@code chatModel.getOptions().mutate()} -- not {@code getDefaultOptions()}, which is a
* separate default method nobody in the request-building path actually calls. Confirmed by
* disassembling both: {@link ChatModel#getOptions()}'s default body is a bare
* {@code ChatOptions.builder().build()}, a plain {@link ChatOptions} that is not a
* {@link ToolCallingChatOptions}. Since {@code ToolCallingAdvisor.adviseCall} starts with an
* {@code instanceof ToolCallingChatOptions} check on that exact object and falls straight
* through to the underlying model with no tool loop at all when it fails, leaving this method's
* default in place silently turns every tool call in this repository into a no-op -- confirmed
* the hard way, by a first version of this class that overrode {@code getDefaultOptions()}
* instead and watched every test below get back an empty answer after exactly one model call.
* Real providers (OpenAI, Anthropic) return their own {@code ToolCallingChatOptions}
* implementation from {@code getOptions()} for the same reason.
*/
@Override
public ChatOptions getOptions() {
return ToolCallingChatOptions.builder().build();
}
public int callCount() {
return this.capturedPrompts.size();
}
public static final class Builder {
private final Deque<ChatResponse> script = new ArrayDeque<>();
private Builder() {
}
/** Queues a plain-text final answer with no tool calls -- ends the tool-calling loop. */
public Builder thenRespond(String text) {
this.script.add(new ChatResponse(List.of(new Generation(new AssistantMessage(text)))));
return this;
}
/** Queues an assistant turn that calls one or more tools, continuing the loop. */
public Builder thenCallTools(AssistantMessage.ToolCall... toolCalls) {
AssistantMessage message = AssistantMessage.builder()
.content("")
.toolCalls(List.of(toolCalls))
.build();
this.script.add(new ChatResponse(List.of(new Generation(message))));
return this;
}
public ScriptedChatModel build() {
return new ScriptedChatModel(new ArrayDeque<>(this.script));
}
}
}
@@ -0,0 +1,47 @@
package com.ankurm.agentskills.support;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} and echoes it to the console. Every console
* block quoted in the article comes out of one of these files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
@Override
public void close() {
out.flush();
try {
Files.createDirectories(path.getParent());
Files.writeString(path, buffer.toString());
}
catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(buffer);
}
}