fix(agent): bound skill discovery and retain verification evidence (#672)

This commit is contained in:
matevip 2026-10-28 03:18:13 -04:00
parent cf1fd92c6d
commit 17a0cf89c6
8 changed files with 277 additions and 88 deletions

View File

@ -46,6 +46,12 @@ public final class TaskFinalizationCoordinator {
describe finishing the task after failure; it does not establish original-operation recovery.
Endorse recovery only for canonical supportedRecoveryCallIds; an empty list means none is established.
Do not invent numeric results or reconstruct omitted results.
Distinguish runtime evidence omission (resultComplete=false) from pagination described inside
a complete tool response. A directory reporting "Showing: 50 of 52" establishes its reported
total of 52 even though only 50 entries are shown; it does not establish the names, details or
status of the unseen entries. Do not require fetching every page to cite an explicitly reported total.
A later filtered lookup with no matches is scoped to that filter; it does not erase or contradict
the entries and totals observed in an earlier directory lookup. Review all available complete outputs.
For literal existence checks, a substring anywhere in the complete observed output satisfies existence.
Do not invent a standalone line, whole-line equality, token-boundary or occurrence-count requirement
unless the CURRENT objective explicitly requires it. A quoted numeric marker such as
@ -110,40 +116,42 @@ public final class TaskFinalizationCoordinator {
return Thread.currentThread().isInterrupted() || tracker!=null && tracker.isStopRequested(conversationId);
}
/** Read existing canonical data only; never execute tools or trust completed-step prose. */
private static Map<String,String> retainedLatestResult(ActionExecutionLedger ledger,
private static Map<String,String> retainedSuccessfulResults(ActionExecutionLedger ledger,
vip.mate.agent.runtime.state.RunState state) {
var latest=ledger.receipts().values().stream()
.filter(r->r.status()==ActionExecutionLedger.Status.SUCCEEDED && r.substantive())
.sorted(java.util.Comparator.comparingLong(ActionExecutionLedger.Receipt::completedAt).reversed()
.thenComparing(ActionExecutionLedger.Receipt::toolCallId)).findFirst();
if (!ledger.latestCompleteResult().isEmpty()) return ledger.latestCompleteResult();
if(latest.isEmpty() || state==null) return Map.of();
var receipt=latest.get();
if(receipt.resultChars()<=512) return Map.of();
java.util.function.Predicate<String> matches=output->output!=null
&& output.length()==receipt.resultChars()
&& output.startsWith(receipt.resultSummary().substring(0,512));
java.util.Set<String> outputs=new java.util.HashSet<>();
var accessor=new vip.mate.agent.graph.state.MateClawStateAccessor(state);
java.util.function.Consumer<org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse> add=response->{
if(receipt.toolCallId().equals(response.id()) && receipt.toolName().equals(response.name())
&& matches.test(response.responseData())) outputs.add(response.responseData());
Map<String,String> retained = new java.util.LinkedHashMap<>(ledger.latestCompleteResult());
if (state == null) return retained;
Map<String,java.util.Set<String>> candidates = new java.util.HashMap<>();
java.util.function.Consumer<org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse> add = response -> {
if (response.id() == null) return;
var receipt = ledger.receipts().get(response.id());
if (receipt == null || receipt.status() != ActionExecutionLedger.Status.SUCCEEDED
|| !receipt.substantive() || !receipt.toolName().equals(response.name())
|| receipt.resultChars() <= 512 || response.responseData() == null
|| response.responseData().length() != receipt.resultChars()
|| !response.responseData().startsWith(receipt.resultSummary().substring(0,512))) return;
candidates.computeIfAbsent(response.id(), ignored -> new java.util.HashSet<>()).add(response.responseData());
};
Object results=state.data().get(vip.mate.agent.graph.state.MateClawStateKeys.TOOL_RESULTS);
if(results instanceof List<?> list) for(Object value:list)
if(value instanceof org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse response) add.accept(response);
for(var message:accessor.messages())
if(message instanceof org.springframework.ai.chat.messages.ToolResponseMessage responses)
var accessor = new vip.mate.agent.graph.state.MateClawStateAccessor(state);
Object results = state.data().get(vip.mate.agent.graph.state.MateClawStateKeys.TOOL_RESULTS);
if (results instanceof List<?> list) for (Object value : list)
if (value instanceof org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse response) add.accept(response);
for (var message : accessor.messages())
if (message instanceof org.springframework.ai.chat.messages.ToolResponseMessage responses)
responses.getResponses().forEach(add);
for(var event:accessor.pendingEvents()) {
if(vip.mate.agent.GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type())
&& receipt.toolCallId().equals(event.data().get("toolCallId"))
&& receipt.toolName().equals(event.data().get("toolName"))
&& Boolean.parseBoolean(String.valueOf(event.data().get("success")))
&& event.data().get("result") instanceof String output && matches.test(output)) outputs.add(output);
for (var event : accessor.pendingEvents()) {
if (vip.mate.agent.GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type())
&& Boolean.parseBoolean(String.valueOf(event.data().get("success")))
&& event.data().get("toolCallId") instanceof String id
&& event.data().get("toolName") instanceof String name
&& event.data().get("result") instanceof String output)
add.accept(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(id,name,output));
}
// Conflicting records for one identity cannot establish which output is authoritative.
return outputs.size()==1?Map.of(receipt.toolCallId(),outputs.iterator().next()):Map.of();
// Canonical retained output wins over display records. Conflicting runtime
// records cannot establish a complete older result for the same call.
candidates.forEach((id, outputs) -> {
if (!retained.containsKey(id) && outputs.size() == 1) retained.put(id,outputs.iterator().next());
});
return retained;
}
public Review review(ChatModel model, String conversationId, String objective, String candidate,
ActionExecutionLedger ledger, String progressHints, int window, int attempts,
@ -154,7 +162,7 @@ public final class TaskFinalizationCoordinator {
+"\nRUNTIME: ACTIVE; iteration/call continuation available="+canContinue
+"; corrective attempts used="+attempts+"; logical calls used="+usedCalls;
int room=window-OUTPUT_TOKENS-TokenEstimator.estimateTokens(INSTRUCTIONS+mandatory)-128;
var retained = retainedLatestResult(ledger, runState);
var retained = retainedSuccessfulResults(ledger, runState);
var latest = ledger.receipts().values().stream()
.filter(r -> r.status() == ActionExecutionLedger.Status.SUCCEEDED && r.substantive())
.sorted(java.util.Comparator.comparingLong(ActionExecutionLedger.Receipt::completedAt).reversed()

View File

@ -65,7 +65,7 @@ public final class ToolLoopGuard {
* content is legitimate (e.g. after an external revert).
*/
static final Set<String> IDEMPOTENT_TOOLS = Set.of(
"read_file", "web_search", "extract_document_text", "extract_pdf_text");
"read_file", "web_search", "extract_document_text", "extract_pdf_text", "listAvailableSkills");
/** Counter-key prefixes inside the stats map. */
private static final String KEY_EXACT_FAILURE = "ef:";

View File

@ -376,13 +376,28 @@ public final class ActionExecutionLedger {
}
}
for (Receipt receipt : successes.stream().skip(1).toList()) {
successful.add(projectedReceipt(receipt, false));
var output = projectedReceipt(receipt, false);
successful.add(output);
facts.put("omittedSuccessfulSubstantiveCallCount", successes.size() - successful.size());
if (vip.mate.agent.context.TokenEstimator.estimateTokens(facts.toString()) > tokenBudget) {
successful.remove(successful.size() - 1);
facts.put("omittedSuccessfulSubstantiveCallCount", successes.size() - successful.size());
break;
}
// Older successful calls can carry distinct facts (directory totals,
// earlier SQL aggregates). Do not discard their available evidence
// merely because a later filtered lookup returned a smaller result.
String full = retainedResults == null ? null : retainedResults.get(receipt.toolCallId());
if (receipt.resultChars() > MAX_RESULT_SUMMARY_CHARS && full != null
&& full.length() == receipt.resultChars()
&& full.startsWith(receipt.resultSummary().substring(0, MAX_RESULT_SUMMARY_CHARS))) {
var preview = output.deepCopy();
output.put("resultSummary", full);
output.put("resultComplete", true);
output.put("omittedResultChars", 0);
if (vip.mate.agent.context.TokenEstimator.estimateTokens(facts.toString()) > tokenBudget)
successful.set(successful.size() - 1, preview);
}
}
return new FactsProjection(facts.toString(), true);
}

View File

@ -313,21 +313,12 @@ public class SkillFileTool {
first to read its instructions, then follow what SKILL.md tells you.
Calling a skill name as a tool will fail with "Tool not found".
Search strategy when looking for a specific skill:
- The default page is 20 of N — if "Showing: 20 of <larger>" appears
and you don't see what you're after, retry with `keyword=<name fragment>`
(matched against name + description, case-insensitive) or raise `limit`
up to 50.
- If the user mentions an exact skill name (e.g. "tencent-meeting-mcp"),
skip this tool and go straight to
`readSkillFile(skillName="<exact-name>", filePath="SKILL.md")` —
that bypasses the catalog truncation entirely and either returns
the skill's instructions or a clear "skill not found" error.
Note: this returns Skills (vendor-installable docs), not Agents.
For Agents, use `listAvailableAgents`.
Returns: A formatted list of active skills with name, icon, and description.
Returns bounded JSON: matchingTotal, statusCounts, a compact names index, and
limited description examples. The names index is independent of the description
limit, so normal catalogs fit in one response. hasMore/nextOffset identify real
additional pages; keep the same filters when using nextOffset.
For a specific skill, keyword matches name and description. An exact known name
can be used directly with readSkillFile. This lists Skills, not Agents.
""")
public String listAvailableSkills(
@JsonProperty(required = false)
@ -343,9 +334,13 @@ public class SkillFileTool {
String status,
@JsonProperty(required = false)
@JsonPropertyDescription("Maximum number of skills to return, default 20, max 50")
@JsonPropertyDescription("Maximum description examples to return, default 20, max 50. The compact names index has an independent size budget.")
Integer limit,
@JsonProperty(required = false)
@JsonPropertyDescription("Zero-based offset into the filtered names index, default 0. Use nextOffset from the previous response with the same filters.")
Integer offset,
@Nullable ToolContext ctx
) {
log.info("Listing available skills");
@ -378,47 +373,76 @@ public class SkillFileTool {
SkillRuntimeService.isRecentlyInstalled(s, recencyCutoff) ? 0 : 1))
.toList();
if (activeSkills.isEmpty()) {
return "No skills are currently available.";
}
return catalogPage(activeSkills, keyword, source, status, safeLimit, offset);
}
// Issue #46: render as a table with the call pattern stated up front,
// instead of a `- **Name** — desc` list that primes the LLM to call
// the names directly as tools.
StringBuilder sb = new StringBuilder();
sb.append("⚠️ These are Skills (documentation packages), NOT directly callable tools.\n");
sb.append("To use any of them, call:\n");
sb.append(" readSkillFile(skillName=\"<name from below>\", filePath=\"SKILL.md\")\n");
sb.append("then follow what SKILL.md tells you (typically `runSkillScript`).\n\n");
sb.append("| Skill name | Status | Description |\n");
sb.append("|------------|--------|-------------|\n");
for (ResolvedSkill skill : activeSkills.stream().limit(safeLimit).toList()) {
sb.append("| `").append(skill.getName()).append("`");
if (skill.getIcon() != null && !skill.getIcon().isBlank()) {
sb.append(" ").append(skill.getIcon());
}
sb.append(" | ").append(statusToken(skill)).append(" | ");
if (skill.getDescription() != null && !skill.getDescription().isBlank()) {
String desc = skill.getDescription();
if (desc.length() > 120) {
desc = desc.substring(0, 120) + "...";
}
sb.append(desc.replace("|", "\\|").replace("\n", " "));
}
sb.append(" |\n");
/** Source-compatible entry point; only the cursor-aware overload is exposed as a tool. */
public String listAvailableSkills(String keyword, String source, String status, Integer limit, ToolContext ctx) {
return listAvailableSkills(keyword, source, status, limit, null, ctx);
}
/** Data reduction before model input, independent of any model instruction or summarization call. */
private static String catalogPage(List<ResolvedSkill> skills, String keyword, String source,
String status, int exampleLimit, Integer requestedOffset) {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
var page = mapper.createObjectNode();
page.put("kind", "skill_catalog");
page.put("matchingTotal", skills.size());
int offset = Math.min(skills.size(), Math.max(0, requestedOffset == null ? 0 : requestedOffset));
page.put("offset", offset);
var filters = page.putObject("filters");
filters.put("keyword", catalogPreview(keyword, 128));
filters.put("source", catalogPreview(source, 32));
filters.put("status", catalogPreview(status, 32));
var counts = page.putObject("statusCounts");
for (var skill : skills) {
String token = statusToken(skill);
counts.put(token, counts.path(token).asInt() + 1);
}
int shown = Math.min(safeLimit, activeSkills.size());
sb.append("\nShowing: ").append(shown)
.append(" of ").append(activeSkills.size()).append(" skill(s).");
if (shown < activeSkills.size()) {
// Surface the truncation hint so the LLM knows how to widen the
// search instead of concluding the missing skill doesn't exist.
sb.append(" Result truncated — retry with `keyword=<part of name>` ")
.append("to search the full catalog, or `limit=50` to see more rows. ")
.append("If the user gave an exact skill name, prefer ")
.append("`readSkillFile(skillName=\"<name>\", filePath=\"SKILL.md\")` directly.");
var names = page.putArray("names");
page.put("hasMore", false);
page.putNull("nextOffset");
var examples = page.putArray("examples");
var usage = page.putObject("usage");
usage.put("tool", "readSkillFile");
usage.put("filePath", "SKILL.md");
int next = offset;
// The compact index supplies names without loading every description.
// Keep room for metadata; pathological names fail explicitly rather than
// returning a cursor that cannot make progress.
while (next < skills.size() && next - offset < 200) {
names.add(skills.get(next).getName());
if (page.toString().length() > MAX_OUTPUT_CHARS - 256) {
names.remove(names.size() - 1);
break;
}
next++;
}
return sb.toString();
if (next == offset && next < skills.size()) {
page.put("error", "Skill name exceeds catalog output budget");
return page.toString();
}
page.put("hasMore", next < skills.size());
if (next < skills.size()) page.put("nextOffset", next);
for (int i = offset; i < next && examples.size() < exampleLimit; i++) {
var skill = skills.get(i);
var item = examples.addObject();
item.put("name", skill.getName());
item.put("status", statusToken(skill));
String description = skill.getDescription();
item.put("description", catalogPreview(description, 120));
item.put("descriptionComplete", description == null || description.length() <= 120);
if (page.toString().length() > MAX_OUTPUT_CHARS) {
examples.remove(examples.size() - 1);
break;
}
}
return page.toString();
}
private static String catalogPreview(String value, int maxChars) {
if (value == null) return "";
return value.length() <= maxChars ? value : value.substring(0, maxChars);
}
private static boolean containsIgnoreCase(String value, String lowerCaseNeedle) {

View File

@ -204,6 +204,58 @@ class TaskFinalizationCoordinatorTest {
});
assertEquals(TaskFinalizationGate.Outcome.REVISE,review(16000,0,0).result().outcome());
}
@Test void olderSkillDirectoryKeepsTotalsAfterLaterFilteredLookup() {
String directory="Skill directory\n"+"| documented skill | description |\n".repeat(40)+"Showing: 50 of 52 skill(s).";
var old=new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE,
Map.of("toolCallId","directory","toolName","listAvailableSkills","result",directory,"success",true),1);
var latest=new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE,
Map.of("toolCallId","filtered","toolName","listAvailableSkills","result","No skills are currently available.","success",true),2);
var ledger=ActionExecutionLedger.fromEvents(List.of(old,latest));
var response=new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse("directory","listAvailableSkills",directory);
for (String source : List.of(MateClawStateKeys.MESSAGES,MateClawStateKeys.TOOL_RESULTS,MateClawStateKeys.PENDING_EVENTS)) {
reset(helper);
when(helper.streamCallSilent(any(),any(),anyString(),anyString())).thenAnswer(call->{
String prompt=((Prompt)call.getArgument(1)).getContents();
assertTrue(prompt.contains("Showing: 50 of 52 skill(s)."),"Earlier complete directory must reach the verifier");
assertTrue(prompt.contains("No skills are currently available."),"Keep the later filtered result too");
return result(json("ACCEPT","[]").replace("\"failedCallCount\":1","\"failedCallCount\":0"));
});
Object retained = source.equals(MateClawStateKeys.MESSAGES)
? List.of(org.springframework.ai.chat.messages.ToolResponseMessage.builder().responses(List.of(response)).build())
: source.equals(MateClawStateKeys.TOOL_RESULTS) ? List.of(response) : List.of(old);
var check=coordinator.review(model,"c","你有哪些技能","目录显示52个技能",ledger,"",16000,0,10,0,true,
new RunState(Map.of(source,retained)));
assertEquals(TaskFinalizationGate.Outcome.ACCEPT,check.result().outcome(),source);
}
}
@Test void olderEvidenceRejectsWrongIdentityLengthAndConflictingCopies() {
String output="x".repeat(600)+" total=52";
var old=new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE,
Map.of("toolCallId","old","toolName","listAvailableSkills","result",output,"success",true),1);
var latest=new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE,
Map.of("toolCallId","new","toolName","listAvailableSkills","result","No matching skills.","success",true),2);
var ledger=ActionExecutionLedger.fromEvents(List.of(old,latest));
var valid=new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse("old","listAvailableSkills",output);
for (var responses : List.of(
List.of(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(null,"listAvailableSkills",output)),
List.of(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse("wrong","listAvailableSkills",output)),
List.of(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse("old","wrong",output)),
List.of(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse("old","listAvailableSkills",output+"extra")),
List.of(valid,new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse("old","listAvailableSkills",output.replace("52","99"))))) {
reset(helper);
when(helper.streamCallSilent(any(),any(),anyString(),anyString())).thenAnswer(call->{
String prompt=((Prompt)call.getArgument(1)).getContents();
assertFalse(prompt.contains("total=52"));assertFalse(prompt.contains("total=99"));
assertTrue(prompt.contains("\"resultComplete\":false"));
return result(json("ACCEPT","[]").replace("\"failedCallCount\":1","\"failedCallCount\":0"));
});
coordinator.review(model,"c","List observed skills","Observed subset",ledger,"",16000,0,10,0,true,
new RunState(Map.of(MateClawStateKeys.TOOL_RESULTS,responses)));
verify(helper).streamCallSilent(any(),any(),anyString(),anyString());
}
}
@Test void latestAggregateAfterPreviewIsGroundedFromExactRetainedResponse() {
String output="x".repeat(600)+" totalSquareSum=2094116873200";
var receipts=ActionExecutionLedger.fromEvents(List.of(GraphEventPublisher.toolComplete("aggregate","execute_code",output,true)));

View File

@ -37,6 +37,19 @@ class ToolLoopGuardTest {
return eval;
}
@Test
void skillCatalogRepeatedReadHaltsButDistinctPagesMakeProgress() {
String page="{\"matchingTotal\":79,\"names\":[\"pdf\"],\"hasMore\":false}";
assertTrue(runRounds(5,"listAvailableSkills","{}",page).shouldHalt());
Map<String,Object> stats=Map.of();
for(int offset=0;offset<10;offset++) {
var eval=ToolLoopGuard.evaluate(stats,
List.of(call("c"+offset,"listAvailableSkills","{\"offset\":"+offset+"}")),
List.of(result("c"+offset,"listAvailableSkills",page+offset)));
assertFalse(eval.shouldHalt());stats=eval.stats();
}
}
// ==================== failure heuristic ====================
@Test

View File

@ -149,6 +149,29 @@ class ActionExecutionLedgerTest {
var output=projection.path("successfulSubstantiveCalls").get(0);
assertFalse(output.path("resultComplete").asBoolean());assertEquals(-1,output.path("omittedResultChars").asInt());
}
@Test void olderCompleteEvidenceFitsOrFallsBackToHonestPreview() throws Exception {
String old="x".repeat(900)+"Showing: 50 of 52 skill(s).";
var ledger=ActionExecutionLedger.fromEvents(List.of(
new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE,java.util.Map.of("toolCallId","old","toolName","listAvailableSkills","result",old,"success",true),1),
new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE,java.util.Map.of("toolCallId","latest","toolName","listAvailableSkills","result","No matching skills.","success",true),2)));
var retained=java.util.Map.of("old",old);
var mapper=new com.fasterxml.jackson.databind.ObjectMapper();
var full=ledger.projectAnswerFacts(3000,retained);
assertTrue(full.coreFactsFit());assertTrue(full.text().contains("Showing: 50 of 52 skill(s)."));
var older=mapper.readTree(full.text()).path("successfulSubstantiveCalls").get(1);
assertTrue(older.path("resultComplete").asBoolean());
assertEquals(0,older.path("omittedResultChars").asInt());
var preview=ledger.projectAnswerFacts(3000,java.util.Map.of());
int previewBudget=vip.mate.agent.context.TokenEstimator.estimateTokens(preview.text());
var constrained=ledger.projectAnswerFacts(previewBudget,retained);
assertTrue(constrained.coreFactsFit());
older=mapper.readTree(constrained.text()).path("successfulSubstantiveCalls").get(1);
assertFalse(older.path("resultComplete").asBoolean());
assertFalse(constrained.text().contains("Showing: 50 of 52 skill(s)."));
var mismatch=ledger.projectAnswerFacts(3000,java.util.Map.of("old","forged"+old));
assertFalse(mismatch.text().contains("Showing: 50 of 52 skill(s)."));
}
@Test void guardedProjectionReservesFullLatestOutputBeforeOptionalOlderReceipts() throws Exception {
String old="old "+"x".repeat(600), latest="y".repeat(600)+" totalSquareSum=2094116873200";
var ledger=ActionExecutionLedger.fromEvents(List.of(

View File

@ -26,6 +26,60 @@ import static org.mockito.Mockito.when;
class SkillFileToolTest {
@Test
void compactOverviewIncludesAll79NamesWithinHardBudget() throws Exception {
var runtime=mock(SkillRuntimeService.class);
var resolver=mock(AgentWorkspaceResolver.class);
when(resolver.resolve(any())).thenReturn(1L);
var tool=new SkillFileTool(runtime,mock(SkillFileAccessPolicy.class),mock(SkillUsageService.class),resolver);
when(runtime.getActiveSkills(any())).thenReturn(java.util.stream.IntStream.range(0,79)
.mapToObj(i->skill("skill-"+i,"database",true)).toList());
String output=tool.listAvailableSkills(null,null,null,null,null);
var json=new com.fasterxml.jackson.databind.ObjectMapper().readTree(output);
assertEquals("skill_catalog",json.path("kind").asText());
assertEquals(79,json.path("matchingTotal").asInt());
assertEquals(79,json.path("names").size());
assertFalse(json.path("hasMore").asBoolean());
assertTrue(json.path("nextOffset").isNull());
assertTrue(json.path("examples").size()<=20);
assertTrue(output.length()<=8000,"The hard output budget must be independent of model behavior");
var names=new java.util.HashSet<String>();json.path("names").forEach(n->names.add(n.asText()));
for(int i=0;i<79;i++)assertTrue(names.contains("skill-"+i));
String filtered=tool.listAvailableSkills("does-not-exist",null,null,null,null);
var empty=new com.fasterxml.jackson.databind.ObjectMapper().readTree(filtered);
assertEquals(0,empty.path("matchingTotal").asInt());
assertEquals("does-not-exist",empty.path("filters").path("keyword").asText());
}
@Test
void cursorPagesAreBoundedAndCoverLargeCatalogWithoutAlphabetSearch() throws Exception {
var runtime=mock(SkillRuntimeService.class);
var resolver=mock(AgentWorkspaceResolver.class);
when(resolver.resolve(any())).thenReturn(1L);
var tool=new SkillFileTool(runtime,mock(SkillFileAccessPolicy.class),mock(SkillUsageService.class),resolver);
var catalog=java.util.stream.IntStream.range(0,450).mapToObj(i->{
var entry=skill("long-name-"+i+"-"+"x".repeat(80),"database",true);
entry.setDescription("very long description".repeat(1000));return entry;
}).toList();
when(runtime.getActiveSkills(any())).thenReturn(catalog);
var mapper=new com.fasterxml.jackson.databind.ObjectMapper();
var names=new java.util.HashSet<String>();int offset=0,pages=0;
while(true) {
String output=tool.listAvailableSkills(null,null,null,50,offset,null);
assertTrue(output.length()<=8000);
var page=mapper.readTree(output);
assertEquals(450,page.path("matchingTotal").asInt());
for(var name:page.path("names"))assertTrue(names.add(name.asText()),"No duplicate names across cursor pages");
pages++;assertTrue(pages<=450);
if(!page.path("hasMore").asBoolean())break;
int next=page.path("nextOffset").asInt();assertTrue(next>offset);offset=next;
}
assertEquals(450,names.size());
assertTrue(pages>1);
var beyond=mapper.readTree(tool.listAvailableSkills(null,null,null,20,1000,null));
assertTrue(beyond.path("names").isEmpty());assertFalse(beyond.path("hasMore").asBoolean());
}
@Test
@DisplayName("listAvailableSkills applies keyword, source, status, and limit")
void listAvailableSkillsFiltersAndLimitsRuntimeCatalog() {
@ -44,7 +98,7 @@ class SkillFileToolTest {
assertTrue(result.contains("claude-code"));
assertFalse(result.contains("ckjia-shopping"));
assertTrue(result.contains("Showing: 1 of 1"));
assertTrue(result.contains("\"matchingTotal\":1"));
}
@Test