diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinator.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinator.java index 14213d65..17d9efeb 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinator.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinator.java @@ -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 retainedLatestResult(ActionExecutionLedger ledger, + private static Map 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 matches=output->output!=null - && output.length()==receipt.resultChars() - && output.startsWith(receipt.resultSummary().substring(0,512)); - java.util.Set outputs=new java.util.HashSet<>(); - var accessor=new vip.mate.agent.graph.state.MateClawStateAccessor(state); - java.util.function.Consumer add=response->{ - if(receipt.toolCallId().equals(response.id()) && receipt.toolName().equals(response.name()) - && matches.test(response.responseData())) outputs.add(response.responseData()); + Map retained = new java.util.LinkedHashMap<>(ledger.latestCompleteResult()); + if (state == null) return retained; + Map> candidates = new java.util.HashMap<>(); + java.util.function.Consumer 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() diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/ToolLoopGuard.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/ToolLoopGuard.java index 33d714cf..d6bbcaad 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/ToolLoopGuard.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/ToolLoopGuard.java @@ -65,7 +65,7 @@ public final class ToolLoopGuard { * content is legitimate (e.g. after an external revert). */ static final Set 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:"; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java index ed258910..3d6acb8d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java @@ -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); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java index 618ae10d..a2927c80 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java @@ -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 " appears - and you don't see what you're after, retry with `keyword=` - (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="", 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=\"\", 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 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=` ") - .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=\"\", 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) { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinatorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinatorTest.java index 2afd43b7..42cf806c 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinatorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinatorTest.java @@ -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))); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/ToolLoopGuardTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/ToolLoopGuardTest.java index 66c498a5..1e90dfc1 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/ToolLoopGuardTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/ToolLoopGuardTest.java @@ -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 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 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java index a0eafafe..27a34e3a 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java @@ -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( diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java index 057c9d14..e63bb1ab 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java @@ -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();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();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