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. 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. Endorse recovery only for canonical supportedRecoveryCallIds; an empty list means none is established.
Do not invent numeric results or reconstruct omitted results. 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. 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 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 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); return Thread.currentThread().isInterrupted() || tracker!=null && tracker.isStopRequested(conversationId);
} }
/** Read existing canonical data only; never execute tools or trust completed-step prose. */ /** 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) { vip.mate.agent.runtime.state.RunState state) {
var latest=ledger.receipts().values().stream() Map<String,String> retained = new java.util.LinkedHashMap<>(ledger.latestCompleteResult());
.filter(r->r.status()==ActionExecutionLedger.Status.SUCCEEDED && r.substantive()) if (state == null) return retained;
.sorted(java.util.Comparator.comparingLong(ActionExecutionLedger.Receipt::completedAt).reversed() Map<String,java.util.Set<String>> candidates = new java.util.HashMap<>();
.thenComparing(ActionExecutionLedger.Receipt::toolCallId)).findFirst(); java.util.function.Consumer<org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse> add = response -> {
if (!ledger.latestCompleteResult().isEmpty()) return ledger.latestCompleteResult(); if (response.id() == null) return;
if(latest.isEmpty() || state==null) return Map.of(); var receipt = ledger.receipts().get(response.id());
var receipt=latest.get(); if (receipt == null || receipt.status() != ActionExecutionLedger.Status.SUCCEEDED
if(receipt.resultChars()<=512) return Map.of(); || !receipt.substantive() || !receipt.toolName().equals(response.name())
java.util.function.Predicate<String> matches=output->output!=null || receipt.resultChars() <= 512 || response.responseData() == null
&& output.length()==receipt.resultChars() || response.responseData().length() != receipt.resultChars()
&& output.startsWith(receipt.resultSummary().substring(0,512)); || !response.responseData().startsWith(receipt.resultSummary().substring(0,512))) return;
java.util.Set<String> outputs=new java.util.HashSet<>(); candidates.computeIfAbsent(response.id(), ignored -> new java.util.HashSet<>()).add(response.responseData());
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());
}; };
Object results=state.data().get(vip.mate.agent.graph.state.MateClawStateKeys.TOOL_RESULTS); var accessor = new vip.mate.agent.graph.state.MateClawStateAccessor(state);
if(results instanceof List<?> list) for(Object value:list) Object results = state.data().get(vip.mate.agent.graph.state.MateClawStateKeys.TOOL_RESULTS);
if(value instanceof org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse response) add.accept(response); if (results instanceof List<?> list) for (Object value : list)
for(var message:accessor.messages()) if (value instanceof org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse response) add.accept(response);
if(message instanceof org.springframework.ai.chat.messages.ToolResponseMessage responses) for (var message : accessor.messages())
if (message instanceof org.springframework.ai.chat.messages.ToolResponseMessage responses)
responses.getResponses().forEach(add); responses.getResponses().forEach(add);
for(var event:accessor.pendingEvents()) { for (var event : accessor.pendingEvents()) {
if(vip.mate.agent.GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type()) if (vip.mate.agent.GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type())
&& receipt.toolCallId().equals(event.data().get("toolCallId")) && Boolean.parseBoolean(String.valueOf(event.data().get("success")))
&& receipt.toolName().equals(event.data().get("toolName")) && event.data().get("toolCallId") instanceof String id
&& Boolean.parseBoolean(String.valueOf(event.data().get("success"))) && event.data().get("toolName") instanceof String name
&& event.data().get("result") instanceof String output && matches.test(output)) outputs.add(output); && 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. // Canonical retained output wins over display records. Conflicting runtime
return outputs.size()==1?Map.of(receipt.toolCallId(),outputs.iterator().next()):Map.of(); // 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, public Review review(ChatModel model, String conversationId, String objective, String candidate,
ActionExecutionLedger ledger, String progressHints, int window, int attempts, ActionExecutionLedger ledger, String progressHints, int window, int attempts,
@ -154,7 +162,7 @@ public final class TaskFinalizationCoordinator {
+"\nRUNTIME: ACTIVE; iteration/call continuation available="+canContinue +"\nRUNTIME: ACTIVE; iteration/call continuation available="+canContinue
+"; corrective attempts used="+attempts+"; logical calls used="+usedCalls; +"; corrective attempts used="+attempts+"; logical calls used="+usedCalls;
int room=window-OUTPUT_TOKENS-TokenEstimator.estimateTokens(INSTRUCTIONS+mandatory)-128; 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() var latest = ledger.receipts().values().stream()
.filter(r -> r.status() == ActionExecutionLedger.Status.SUCCEEDED && r.substantive()) .filter(r -> r.status() == ActionExecutionLedger.Status.SUCCEEDED && r.substantive())
.sorted(java.util.Comparator.comparingLong(ActionExecutionLedger.Receipt::completedAt).reversed() .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). * content is legitimate (e.g. after an external revert).
*/ */
static final Set<String> IDEMPOTENT_TOOLS = Set.of( 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. */ /** Counter-key prefixes inside the stats map. */
private static final String KEY_EXACT_FAILURE = "ef:"; 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()) { 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()); facts.put("omittedSuccessfulSubstantiveCallCount", successes.size() - successful.size());
if (vip.mate.agent.context.TokenEstimator.estimateTokens(facts.toString()) > tokenBudget) { if (vip.mate.agent.context.TokenEstimator.estimateTokens(facts.toString()) > tokenBudget) {
successful.remove(successful.size() - 1); successful.remove(successful.size() - 1);
facts.put("omittedSuccessfulSubstantiveCallCount", successes.size() - successful.size()); facts.put("omittedSuccessfulSubstantiveCallCount", successes.size() - successful.size());
break; 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); 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. 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". Calling a skill name as a tool will fail with "Tool not found".
Search strategy when looking for a specific skill: Returns bounded JSON: matchingTotal, statusCounts, a compact names index, and
- The default page is 20 of N — if "Showing: 20 of <larger>" appears limited description examples. The names index is independent of the description
and you don't see what you're after, retry with `keyword=<name fragment>` limit, so normal catalogs fit in one response. hasMore/nextOffset identify real
(matched against name + description, case-insensitive) or raise `limit` additional pages; keep the same filters when using nextOffset.
up to 50. For a specific skill, keyword matches name and description. An exact known name
- If the user mentions an exact skill name (e.g. "tencent-meeting-mcp"), can be used directly with readSkillFile. This lists Skills, not Agents.
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.
""") """)
public String listAvailableSkills( public String listAvailableSkills(
@JsonProperty(required = false) @JsonProperty(required = false)
@ -343,9 +334,13 @@ public class SkillFileTool {
String status, String status,
@JsonProperty(required = false) @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, 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 @Nullable ToolContext ctx
) { ) {
log.info("Listing available skills"); log.info("Listing available skills");
@ -378,47 +373,76 @@ public class SkillFileTool {
SkillRuntimeService.isRecentlyInstalled(s, recencyCutoff) ? 0 : 1)) SkillRuntimeService.isRecentlyInstalled(s, recencyCutoff) ? 0 : 1))
.toList(); .toList();
if (activeSkills.isEmpty()) { return catalogPage(activeSkills, keyword, source, status, safeLimit, offset);
return "No skills are currently available."; }
}
// Issue #46: render as a table with the call pattern stated up front, /** Source-compatible entry point; only the cursor-aware overload is exposed as a tool. */
// instead of a `- **Name** — desc` list that primes the LLM to call public String listAvailableSkills(String keyword, String source, String status, Integer limit, ToolContext ctx) {
// the names directly as tools. return listAvailableSkills(keyword, source, status, limit, null, ctx);
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"); /** Data reduction before model input, independent of any model instruction or summarization call. */
sb.append(" readSkillFile(skillName=\"<name from below>\", filePath=\"SKILL.md\")\n"); private static String catalogPage(List<ResolvedSkill> skills, String keyword, String source,
sb.append("then follow what SKILL.md tells you (typically `runSkillScript`).\n\n"); String status, int exampleLimit, Integer requestedOffset) {
sb.append("| Skill name | Status | Description |\n"); var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
sb.append("|------------|--------|-------------|\n"); var page = mapper.createObjectNode();
for (ResolvedSkill skill : activeSkills.stream().limit(safeLimit).toList()) { page.put("kind", "skill_catalog");
sb.append("| `").append(skill.getName()).append("`"); page.put("matchingTotal", skills.size());
if (skill.getIcon() != null && !skill.getIcon().isBlank()) { int offset = Math.min(skills.size(), Math.max(0, requestedOffset == null ? 0 : requestedOffset));
sb.append(" ").append(skill.getIcon()); page.put("offset", offset);
} var filters = page.putObject("filters");
sb.append(" | ").append(statusToken(skill)).append(" | "); filters.put("keyword", catalogPreview(keyword, 128));
if (skill.getDescription() != null && !skill.getDescription().isBlank()) { filters.put("source", catalogPreview(source, 32));
String desc = skill.getDescription(); filters.put("status", catalogPreview(status, 32));
if (desc.length() > 120) { var counts = page.putObject("statusCounts");
desc = desc.substring(0, 120) + "..."; for (var skill : skills) {
} String token = statusToken(skill);
sb.append(desc.replace("|", "\\|").replace("\n", " ")); counts.put(token, counts.path(token).asInt() + 1);
}
sb.append(" |\n");
} }
int shown = Math.min(safeLimit, activeSkills.size()); var names = page.putArray("names");
sb.append("\nShowing: ").append(shown) page.put("hasMore", false);
.append(" of ").append(activeSkills.size()).append(" skill(s)."); page.putNull("nextOffset");
if (shown < activeSkills.size()) { var examples = page.putArray("examples");
// Surface the truncation hint so the LLM knows how to widen the var usage = page.putObject("usage");
// search instead of concluding the missing skill doesn't exist. usage.put("tool", "readSkillFile");
sb.append(" Result truncated — retry with `keyword=<part of name>` ") usage.put("filePath", "SKILL.md");
.append("to search the full catalog, or `limit=50` to see more rows. ") int next = offset;
.append("If the user gave an exact skill name, prefer ") // The compact index supplies names without loading every description.
.append("`readSkillFile(skillName=\"<name>\", filePath=\"SKILL.md\")` directly."); // 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) { 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()); 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() { @Test void latestAggregateAfterPreviewIsGroundedFromExactRetainedResponse() {
String output="x".repeat(600)+" totalSquareSum=2094116873200"; String output="x".repeat(600)+" totalSquareSum=2094116873200";
var receipts=ActionExecutionLedger.fromEvents(List.of(GraphEventPublisher.toolComplete("aggregate","execute_code",output,true))); var receipts=ActionExecutionLedger.fromEvents(List.of(GraphEventPublisher.toolComplete("aggregate","execute_code",output,true)));

View File

@ -37,6 +37,19 @@ class ToolLoopGuardTest {
return eval; 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 ==================== // ==================== failure heuristic ====================
@Test @Test

View File

@ -149,6 +149,29 @@ class ActionExecutionLedgerTest {
var output=projection.path("successfulSubstantiveCalls").get(0); var output=projection.path("successfulSubstantiveCalls").get(0);
assertFalse(output.path("resultComplete").asBoolean());assertEquals(-1,output.path("omittedResultChars").asInt()); 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 { @Test void guardedProjectionReservesFullLatestOutputBeforeOptionalOlderReceipts() throws Exception {
String old="old "+"x".repeat(600), latest="y".repeat(600)+" totalSquareSum=2094116873200"; String old="old "+"x".repeat(600), latest="y".repeat(600)+" totalSquareSum=2094116873200";
var ledger=ActionExecutionLedger.fromEvents(List.of( var ledger=ActionExecutionLedger.fromEvents(List.of(

View File

@ -26,6 +26,60 @@ import static org.mockito.Mockito.when;
class SkillFileToolTest { 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 @Test
@DisplayName("listAvailableSkills applies keyword, source, status, and limit") @DisplayName("listAvailableSkills applies keyword, source, status, and limit")
void listAvailableSkillsFiltersAndLimitsRuntimeCatalog() { void listAvailableSkillsFiltersAndLimitsRuntimeCatalog() {
@ -44,7 +98,7 @@ class SkillFileToolTest {
assertTrue(result.contains("claude-code")); assertTrue(result.contains("claude-code"));
assertFalse(result.contains("ckjia-shopping")); assertFalse(result.contains("ckjia-shopping"));
assertTrue(result.contains("Showing: 1 of 1")); assertTrue(result.contains("\"matchingTotal\":1"));
} }
@Test @Test