mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 22:16:58 +08:00
fix(agent): bound skill discovery and retain verification evidence (#672)
This commit is contained in:
parent
cf1fd92c6d
commit
17a0cf89c6
@ -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()
|
||||||
|
|||||||
@ -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:";
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)));
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user