From 6c0a60ce14f6b8c535911a908a94875d1fe4bb7e Mon Sep 17 00:00:00 2001
From: mateaix <7333791@qq.com>
Date: Tue, 15 Sep 2026 02:16:14 +0800
Subject: [PATCH] fix(goal): keep managed JSON checks repeatable in progress
hints
---
.../vip/mate/agent/graph/node/ActionNode.java | 8 +++--
.../goal/GoalJsonGraphIntegrationTest.java | 2 ++
.../GoalJsonHttpRuntimeIntegrationTest.java | 31 +++++++++++++++++--
3 files changed, 37 insertions(+), 4 deletions(-)
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java
index 3092c08e..fda80429 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java
@@ -61,7 +61,7 @@ public class ActionNode implements NodeAction {
/**
* Tools whose results should NOT be auto-recorded into the ledger.
- * Two groups:
+ * Three groups:
*
* - Meta-tools (load_skill, enable_tool, progress_update,
* skill helpers) — they either have their own ledger side-effects
@@ -72,6 +72,8 @@ public class ActionNode implements NodeAction {
* answer follow-up questions from stale output instead of
* re-checking, because the snapshot instructs "已完成的步骤不要
* 重复执行".
+ * - Time-bound verification — managed JSON bindings may need
+ * a new check during the same loop after a goal definition changes.
*
*/
private static final Set AUTO_RECORD_SKIP = Set.of(
@@ -82,7 +84,9 @@ public class ActionNode implements NodeAction {
"extract_document_text", "extract_pdf_text", "extract_docx_text",
"detect_file_type",
"getCurrentDateTime", "getCurrentDate", "getCurrentTime",
- "listSubagents", "getManagedGoalJsonSlots"
+ "listSubagents", "getManagedGoalJsonSlots",
+ // A binding is time-bound and can need refreshing during this same tool loop.
+ "checkManagedGoalJson"
);
private final ToolExecutionExecutor executor;
diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonGraphIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonGraphIntegrationTest.java
index 37ab894f..9aa7cc85 100644
--- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonGraphIntegrationTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonGraphIntegrationTest.java
@@ -149,6 +149,8 @@ class GoalJsonGraphIntegrationTest {
}
assertFalse(progress.load(conversation).asMap().containsKey("auto_getManagedGoalJsonSlots"),
"Current acceptance reads must not become a permanent done step that discourages reloading");
+ assertFalse(progress.load(conversation).asMap().containsKey("auto_checkManagedGoalJson"),
+ "A time-bound JSON binding must not become a done step that discourages rechecking in this tool loop");
assertTrue(calls.get() >= (automatic ? 5 : 6) && calls.get() <= 10, "Bounded scripted model calls: " + calls.get());
}
@org.junit.jupiter.params.ParameterizedTest
diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java
index 838bd5a0..b29daf86 100644
--- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java
@@ -67,10 +67,11 @@ class GoalJsonHttpRuntimeIntegrationTest {
@org.junit.jupiter.params.provider.CsvSource({"false,sync,true", "true,sync,true", "false,stream,true", "true,stream,true",
"false,scheduled,true", "true,scheduled,true", "false,recovered,true", "true,recovered,true",
"false,scheduled,false", "true,scheduled,false", "false,recovered,false", "true,recovered,false",
- "false,queued,true", "false,reuse,true", "true,reuse,true"})
+ "false,queued,true", "false,reuse,true", "true,reuse,true", "false,recheck,true", "true,recheck,true"})
void authenticatedGoalCompletesThroughHttpOrScheduledProductionRuntime(boolean plan, String entry, boolean accepted) throws Exception {
boolean scheduled = entry.equals("scheduled") || entry.equals("recovered");
boolean reuse = entry.equals("reuse");
+ boolean recheck = entry.equals("recheck");
boolean queued = entry.equals("queued");
boolean recovered = entry.equals("recovered");
String username = "http-json-" + UUID.randomUUID();
@@ -138,6 +139,7 @@ class GoalJsonHttpRuntimeIntegrationTest {
ChatModel model = mock(ChatModel.class);
AtomicInteger calls = new AtomicInteger();
java.util.concurrent.atomic.AtomicReference revision = new java.util.concurrent.atomic.AtomicReference<>();
+ java.util.concurrent.atomic.AtomicReference originalCheck = new java.util.concurrent.atomic.AtomicReference<>();
org.mockito.stubbing.Answer script = invocation -> {
Prompt prompt = invocation.getArgument(0);
int step = calls.getAndIncrement();
@@ -161,6 +163,23 @@ class GoalJsonHttpRuntimeIntegrationTest {
.flatMap(m -> m.getResponses().stream()).toList();
JsonNode last = responses.isEmpty() ? null : json.readTree(responses.getLast().responseData());
String name; String arguments = "{}";
+ if (recheck && step >= 5 && step <= 7) {
+ if (step == 5) {
+ assertTrue(last.path("error").asBoolean(), String.valueOf(last));
+ name = "getManagedGoalJsonSlots";
+ } else if (step == 6) {
+ assertEquals("GOAL_CHANGED", last.path("checks").get(0).path("status").asText(), String.valueOf(last));
+ assertEquals(1, last.path("versionCount").asInt());
+ name = "checkManagedGoalJson";
+ arguments = originalCheck.get();
+ assertNotNull(arguments);
+ } else {
+ assertTrue(last.path("acceptanceEligible").asBoolean(), String.valueOf(last));
+ name = "completeGoal";
+ }
+ return new ChatResponse(List.of(new Generation(AssistantMessage.builder().content("")
+ .toolCalls(List.of(new AssistantMessage.ToolCall("recheck-" + step, "function", name, arguments))).build())));
+ }
switch (step) {
case 0 -> name = "completeGoal";
case 1 -> { assertTrue(last.path("error").asBoolean(), String.valueOf(last)); name = "getManagedGoalJsonSlots"; }
@@ -187,10 +206,16 @@ class GoalJsonHttpRuntimeIntegrationTest {
name = "checkManagedGoalJson";
arguments = json.writeValueAsString(Map.of("criterionKey", "r", "expectedRequirementRevision", revision.get(),
"artifactId", last.path("artifactId").asText(), "expectedGeneration", last.path("generation").asText()));
+ originalCheck.set(arguments);
}
}
case 4 -> {
assertTrue((reuse ? last.path("checks").get(0) : last).path("acceptanceEligible").asBoolean(), String.valueOf(last));
+ if (recheck) {
+ // Simulate a user definition edit between the first check and completion.
+ var edit = new GoalUpdateRequest(); edit.setDescription("Revised report context");
+ goals.update(goal.getId(), edit, username);
+ }
name = "completeGoal";
}
default -> {
@@ -264,9 +289,11 @@ class GoalJsonHttpRuntimeIntegrationTest {
}
assertEquals(accepted ? GoalStatus.COMPLETED : GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus());
assertEquals(accepted, bindings.state(goal.getId(), username).getFirst().acceptanceEligible());
- assertTrue(calls.get() >= (accepted ? 6 : 2) && calls.get() <= (accepted ? 10 : 4), "Bounded offline model calls: " + calls.get());
+ assertTrue(calls.get() >= (accepted ? 6 : 2) && calls.get() <= (recheck ? 12 : accepted ? 10 : 4), "Bounded offline model calls: " + calls.get());
if (!accepted) assertEquals(recovered ? 1 : 0,
jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId()));
+ if (recheck) assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId()),
+ "A changed goal definition requires a fresh binding, not another publication of unchanged bytes");
if (reuse) assertEquals(32, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId()),
"Checking and completing a current version must not consume another publication");
JsonNode currentRequirements = request("GET", "/api/v1/goals/" + goal.getId() + "/json-acceptance", token, null);