fix: keep managed JSON goals running until completion is committed

This commit is contained in:
mateaix 2026-09-14 21:57:01 +08:00
parent 4277172529
commit 4a7f0e211e
14 changed files with 134 additions and 3 deletions

View File

@ -730,6 +730,10 @@ public class AgentBindingService implements AgentBindingResolver {
"addGoalCriterion",
"completeGoal",
"getGoalStatus",
// User-selected managed JSON protocol, authorized again inside each tool service.
"getManagedGoalJsonSlots",
"publishManagedGoalJson",
"checkManagedGoalJson",
"waitForGoalInput",
// Conversation-scoped progress ledger same rationale as the
// goal primitives above. Long multi-step research / drafting

View File

@ -700,6 +700,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
goalService.findActiveByConversation(conversationId);
if (active != null) {
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
if (active.isJsonAcceptanceRequired()) {
inputs.put(SYSTEM_PROMPT, inputs.get(SYSTEM_PROMPT) + "\n\n"
+ vip.mate.goal.service.GoalJsonProtocolHints.INSTRUCTIONS);
}
}
} catch (Exception e) {
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());

View File

@ -232,8 +232,15 @@ public class GoalEvaluationNode implements NodeAction {
} catch (Throwable t) {
log.warn("[GoalEvaluationNode] terminal write failed for goal={} — degrading to evaluated-only: {}",
refreshed.getId(), t.toString());
Map<String, Object> outward = result.toMap();
if (refreshed.isJsonAcceptanceRequired() && result.completed()) {
outward.put("completed", false);
outward.put("decision", GoalEvaluationResult.DECISION_CONTINUE);
outward.put("gap", "Managed JSON completion was not committed. "
+ vip.mate.goal.service.GoalJsonProtocolHints.INSTRUCTIONS);
}
return MateClawStateAccessor.output()
.goalEvaluationResult(result.toMap())
.goalEvaluationResult(outward)
.goalEvaluatedThisRun(true)
.events(List.of(skippedEvent(refreshed.getId(), "terminal_write_failed")))
.build();

View File

@ -389,6 +389,10 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
goalService.findActiveByConversation(conversationId);
if (active != null) {
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
if (active.isJsonAcceptanceRequired()) {
inputs.put(MateClawStateKeys.SYSTEM_PROMPT, inputs.get(MateClawStateKeys.SYSTEM_PROMPT) + "\n\n"
+ vip.mate.goal.service.GoalJsonProtocolHints.INSTRUCTIONS);
}
}
} catch (Exception e) {
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());

View File

@ -46,7 +46,8 @@ public class GoalFollowupService {
boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution());
boolean claimedComplete = !fallback && (result.completed()
|| GoalEvaluationResult.DECISION_COMPLETED.equals(result.decision()));
boolean completionUnverified = claimedComplete && persistent && !hasVerifiedChecklist(goal);
boolean completionUnverified = claimedComplete && (goal.isJsonAcceptanceRequired()
|| (persistent && !hasVerifiedChecklist(goal)));
if (claimedComplete && !completionUnverified) {
return decision(Action.COMPLETE, null, null, "criteria_completed");
}
@ -125,6 +126,7 @@ public class GoalFollowupService {
if (gap != null && !gap.isBlank()) {
prompt.append("\nLatest evaluation: ").append(bounded(gap, 1000));
}
if (goal.isJsonAcceptanceRequired()) prompt.append("\n").append(GoalJsonProtocolHints.INSTRUCTIONS);
if (persistent) {
prompt.append("\nIf essential input or permission is still unavailable after checking existing state, ")
.append("call waitForGoalInput with the precise missing requirement and ask the user once. ")

View File

@ -0,0 +1,17 @@
package vip.mate.goal.service;
/** Stable runtime guidance; user-controlled requirement text is retrieved through authorized tools. */
public final class GoalJsonProtocolHints {
private GoalJsonProtocolHints() { }
public static final String INSTRUCTIONS = """
This goal has user-selected managed JSON acceptance requirements. Before claiming completion,
call getManagedGoalJsonSlots to read current requirements and generations. Produce the requested
JSON using publishManagedGoalJson, then call checkManagedGoalJson for every requirement using
its exact current revision, artifact ID and generation. Publishing a version alone is not a check.
A new version, an edited requirement or goal definition, or expiry invalidates earlier bindings.
Reload after conflicts and check current versions; do not invent PASS results, overwrite blindly,
or substitute ordinary file checks or textual claims. Existing semantic criteria still apply.
Only the platform's committed Goal status establishes completion. If runtime identity or access
is unavailable, report the precise missing access instead of claiming success.
""";
}

View File

@ -100,9 +100,15 @@ public class GoalRunCoordinator {
private Settlement classify(ClaimedRun run,SegmentOutcome outcome,GoalEntity fresh,LocalDateTime now) {
int failures=run.candidate().failures();
if(fresh!=null && fresh.getStatus()==GoalStatus.COMPLETED || outcome instanceof SegmentOutcome.Complete) {
if(fresh!=null && fresh.getStatus()==GoalStatus.COMPLETED
|| outcome instanceof SegmentOutcome.Complete && (fresh==null || !fresh.isJsonAcceptanceRequired())) {
return new Settlement("succeeded","completed",now,0,"goal_completed",null);
}
if(outcome instanceof SegmentOutcome.Complete && fresh!=null && fresh.isJsonAcceptanceRequired()) {
return eligible(fresh)
? new Settlement("retryable","retry",now.plusSeconds(5),Math.min(1000,failures+1),"json_completion_not_committed","acceptance")
: new Settlement("cancelled","paused",now,0,"goal_not_runnable",null);
}
if(fresh!=null && fresh.getStatus()==GoalStatus.PAUSED && goals.isBudgetExhausted(fresh)) {
return new Settlement("succeeded","budget_limited",now,0,goals.exhaustionReason(fresh),null);
}

View File

@ -32,3 +32,5 @@ After publication, call `POST /checks/{criterionKey}` with `expectedRequirementR
`GET /checks` reads each requirement's current eligibility. Requirement edits, goal-definition edits, a new slot version, expiry or failed body integrity checks invalidate previous bindings. Recheck the current inputs. Binding and goal-version updates share a transaction; rollback cannot leave a passing credential. Historical diagnostic APIs retain `acceptanceEligible=false`; only managed checks create bindings. Completion events retain the accepted requirement revisions, artifact IDs and generations. Transaction rollback emits neither a completion event nor completion memory.
This is an explicit per-goal managed JSON protocol with a limited scope. The broad execution-evidence ledger retains its existing prerequisites for global ENFORCE. Ordinary tool-success text and diagnostic MATCH results never become bindings automatically. Backend services cover success, invalidation, races and rollback; full browser/service flows, restart and external database validation are still in progress.
ReAct, Plan and persistent-goal continuations receive managed JSON instructions. Business-skill tool allowlists retain the three goal-level read, publish and check tools, while service identity checks and child-agent restrictions still apply. For selected goals, follow-up and scheduling projections cannot end on a model completion claim or segment Complete alone: the Goal must already have committed completed status. Rejected automatic completion produces a continue result with recheck guidance.

View File

@ -32,3 +32,5 @@
`GET /checks` 读取每条要求的当前资格。要求修改、Goal 定义修改、槽出现新版本、版本过期或正文完整性失败都会使旧绑定失效;需要按当前条件重新检查。每次绑定与 Goal version 更新同事务,失败回滚不留下通过凭据。历史诊断接口的 `acceptanceEligible=false` 保持不变,只有此受管版本检查产生绑定。完成事件保留本次受管绑定的条件修订、产物 ID 和 generation 引用;事务回滚不发布完成事件或完成记忆。
这是逐 Goal 显式选择的有限受管 JSON 协议;宽泛执行证据账本的全局 ENFORCE 配置仍遵循原有准入限制。此协议不把任何普通工具成功文本或诊断 MATCH 自动升级为绑定。后端服务已覆盖成功、失效、竞争和回滚;完整浏览器服务闭环、重启和外部数据库验证仍在推进。
代理在 ReAct、Plan 和持久 Goal 续跑入口都会收到受管 JSON 操作指引。业务技能的工具白名单保留读取、发布和检查这三个 Goal 通用工具仍执行服务端身份校验与子代理禁用。选中模式下follow-up 和调度投影不能凭模型的“已完成”或 segment Complete 声明结束;必须先有已提交的 Goal completed 状态。自动完成被拒绝时,向运行时返回 continue 和重检指引,不暴露已接受完成的信号。

View File

@ -534,6 +534,8 @@ class AgentBindingServiceTest {
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
assertNotNull(effective, "toolsDisabled=true 时绝不能返回 null那会让全局默认工具又流回来");
assertTrue(effective.contains("record_lesson"), "system-level memory 工具必须保留");
assertTrue(effective.containsAll(Set.of("getManagedGoalJsonSlots", "publishManagedGoalJson", "checkManagedGoalJson")),
"选中的 JSON 要求不能因业务技能绑定失去受管发布和检查入口");
boolean hasMcp = effective.stream().anyMatch(n -> n != null && n.startsWith("mcp_"));
assertFalse(hasMcp,
"toolsDisabled=true 时 enabled MCP 工具绝不能自动并入 —— 否则用户的 '禁用所有工具' 意图被违背。"

View File

@ -231,6 +231,21 @@ class GoalEvaluationNodeContinuationTest {
assertEquals(Boolean.TRUE, out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN));
}
@Test void rejectedManagedCompletionDoesNotExposeACompletedResult() throws Exception {
Fixture f = new Fixture();
GoalEntity goal = new GoalEntity(); goal.setId(1L); goal.setJsonAcceptanceRequired(true);
when(f.goalService.getById(1L)).thenReturn(goal);
var claim = new GoalEvaluationResult(1, "done", "completed", true, "fixture", 1, 0, List.of(), null);
when(f.evaluationService.evaluate(any(), anyList(), anyString())).thenReturn(claim);
when(f.goalService.markEvaluatedCompleted(1L, claim)).thenThrow(new vip.mate.exception.MateClawException(409, "binding missing"));
var out = f.node().apply(f.state(FinishReason.NORMAL.getValue(), 0, 0));
var result = (Map<?, ?>) out.get(MateClawStateKeys.GOAL_EVALUATION_RESULT);
assertEquals(false, result.get("completed"));
assertEquals("continue", result.get("decision"));
assertTrue(result.get("gap").toString().contains("checkManagedGoalJson"));
verify(f.goalService, never()).markCompleted(any(), any());
}
// ===== Test fixture =====
private static final class Fixture {

View File

@ -0,0 +1,40 @@
package vip.mate.goal;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.test.util.ReflectionTestUtils;
import vip.mate.agent.BaseAgent;
import vip.mate.agent.graph.StateGraphReActAgent;
import vip.mate.agent.graph.plan.StateGraphPlanExecuteAgent;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.service.GoalService;
import vip.mate.workspace.conversation.ConversationService;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class GoalJsonProtocolPromptTest {
@ParameterizedTest @ValueSource(booleans = {false, true})
void bothGraphEntryPointsExplainManagedProtocolOnlyForSelectedGoals(boolean plan) {
var conversations = mock(ConversationService.class);
var client = mock(ChatClient.class);
BaseAgent agent = plan ? new StateGraphPlanExecuteAgent(client, conversations, null, null, null, null)
: new StateGraphReActAgent(client, conversations, null, null, null);
var goals = mock(GoalService.class);
var goal = new GoalEntity(); goal.setId(1L); goal.setJsonAcceptanceRequired(true);
when(goals.findActiveByConversation("conv")).thenReturn(goal);
ReflectionTestUtils.setField(agent, "goalService", goals);
ReflectionTestUtils.setField(agent, "systemPrompt", "base instructions");
Map<String, Object> selected = ReflectionTestUtils.invokeMethod(agent, "buildInitialState", "write report", "conv");
assertNotNull(selected);
assertTrue(selected.get(MateClawStateKeys.SYSTEM_PROMPT).toString().contains("checkManagedGoalJson"));
assertTrue(selected.get(MateClawStateKeys.SYSTEM_PROMPT).toString().startsWith("base instructions"));
goal.setJsonAcceptanceRequired(false);
Map<String, Object> legacy = ReflectionTestUtils.invokeMethod(agent, "buildInitialState", "write report", "conv");
assertNotNull(legacy);
assertEquals("base instructions", legacy.get(MateClawStateKeys.SYSTEM_PROMPT));
}
}

View File

@ -280,4 +280,19 @@ class GoalFollowupServiceTest {
assertTrue(prompt.contains("difficulty"));
assertTrue(prompt.contains("time"));
}
@Test void selectedJsonGoalRequiresCommittedCompletionEvenWithPassingChecklist() {
GoalEntity goal = goal(true);
goal.setPersistentExecution(true);
goal.setJsonAcceptanceRequired(true);
goal.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":true,\"evidence\":\"claimed\"}]");
var claimed = res(1, GoalEvaluationResult.DECISION_COMPLETED);
var decision = svc.decide(goal, claimed, LocalDateTime.now());
assertEquals(Action.RETRY, decision.action());
assertTrue(decision.prompt().contains("getManagedGoalJsonSlots"));
assertTrue(decision.prompt().contains("publishManagedGoalJson"));
assertTrue(decision.prompt().contains("checkManagedGoalJson"));
goal.setStatus(GoalStatus.COMPLETED);
assertEquals(Action.COMPLETE, svc.decide(goal, claimed, LocalDateTime.now()).action());
}
}

View File

@ -87,4 +87,15 @@ class GoalRunCoordinatorTest {
assertTrue(coordinator.settle(second,new SegmentOutcome.Continue("unfinished"),secondStart));
assertEquals(secondStart.plusSeconds(600),continuations.get(1L).nextRunAt());
}
@Test void selectedJsonGoalCannotSettleCompletedFromSegmentClaimAlone() {
goal.setJsonAcceptanceRequired(true);
var run=coordinator.claim(continuations.get(1L),goal,now);
assertNotNull(run);
assertTrue(coordinator.markRunning(run,now));
assertTrue(coordinator.settle(run,new SegmentOutcome.Complete("model claim"),now));
assertEquals("retry",continuations.get(1L).state());
assertEquals("retryable",attempts.get(run.attempt().id()).state());
assertEquals("json_completion_not_committed",continuations.get(1L).reason());
}
}