mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
fix: keep managed JSON goals running until completion is committed
This commit is contained in:
parent
4277172529
commit
4a7f0e211e
@ -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
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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. ")
|
||||
|
||||
@ -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.
|
||||
""";
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 和重检指引,不暴露已接受完成的信号。
|
||||
|
||||
@ -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 工具绝不能自动并入 —— 否则用户的 '禁用所有工具' 意图被违背。"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user