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",
|
"addGoalCriterion",
|
||||||
"completeGoal",
|
"completeGoal",
|
||||||
"getGoalStatus",
|
"getGoalStatus",
|
||||||
|
// User-selected managed JSON protocol, authorized again inside each tool service.
|
||||||
|
"getManagedGoalJsonSlots",
|
||||||
|
"publishManagedGoalJson",
|
||||||
|
"checkManagedGoalJson",
|
||||||
"waitForGoalInput",
|
"waitForGoalInput",
|
||||||
// Conversation-scoped progress ledger — same rationale as the
|
// Conversation-scoped progress ledger — same rationale as the
|
||||||
// goal primitives above. Long multi-step research / drafting
|
// goal primitives above. Long multi-step research / drafting
|
||||||
|
|||||||
@ -700,6 +700,10 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
goalService.findActiveByConversation(conversationId);
|
goalService.findActiveByConversation(conversationId);
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
|
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) {
|
} catch (Exception e) {
|
||||||
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
|
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
|
||||||
|
|||||||
@ -232,8 +232,15 @@ public class GoalEvaluationNode implements NodeAction {
|
|||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
log.warn("[GoalEvaluationNode] terminal write failed for goal={} — degrading to evaluated-only: {}",
|
log.warn("[GoalEvaluationNode] terminal write failed for goal={} — degrading to evaluated-only: {}",
|
||||||
refreshed.getId(), t.toString());
|
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()
|
return MateClawStateAccessor.output()
|
||||||
.goalEvaluationResult(result.toMap())
|
.goalEvaluationResult(outward)
|
||||||
.goalEvaluatedThisRun(true)
|
.goalEvaluatedThisRun(true)
|
||||||
.events(List.of(skippedEvent(refreshed.getId(), "terminal_write_failed")))
|
.events(List.of(skippedEvent(refreshed.getId(), "terminal_write_failed")))
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@ -389,6 +389,10 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
goalService.findActiveByConversation(conversationId);
|
goalService.findActiveByConversation(conversationId);
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
inputs.put(MateClawStateKeys.ACTIVE_GOAL, active);
|
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) {
|
} catch (Exception e) {
|
||||||
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
|
log.warn("[{}] findActiveByConversation failed: {}", agentName, e.getMessage());
|
||||||
|
|||||||
@ -46,7 +46,8 @@ public class GoalFollowupService {
|
|||||||
boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution());
|
boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution());
|
||||||
boolean claimedComplete = !fallback && (result.completed()
|
boolean claimedComplete = !fallback && (result.completed()
|
||||||
|| GoalEvaluationResult.DECISION_COMPLETED.equals(result.decision()));
|
|| GoalEvaluationResult.DECISION_COMPLETED.equals(result.decision()));
|
||||||
boolean completionUnverified = claimedComplete && persistent && !hasVerifiedChecklist(goal);
|
boolean completionUnverified = claimedComplete && (goal.isJsonAcceptanceRequired()
|
||||||
|
|| (persistent && !hasVerifiedChecklist(goal)));
|
||||||
if (claimedComplete && !completionUnverified) {
|
if (claimedComplete && !completionUnverified) {
|
||||||
return decision(Action.COMPLETE, null, null, "criteria_completed");
|
return decision(Action.COMPLETE, null, null, "criteria_completed");
|
||||||
}
|
}
|
||||||
@ -125,6 +126,7 @@ public class GoalFollowupService {
|
|||||||
if (gap != null && !gap.isBlank()) {
|
if (gap != null && !gap.isBlank()) {
|
||||||
prompt.append("\nLatest evaluation: ").append(bounded(gap, 1000));
|
prompt.append("\nLatest evaluation: ").append(bounded(gap, 1000));
|
||||||
}
|
}
|
||||||
|
if (goal.isJsonAcceptanceRequired()) prompt.append("\n").append(GoalJsonProtocolHints.INSTRUCTIONS);
|
||||||
if (persistent) {
|
if (persistent) {
|
||||||
prompt.append("\nIf essential input or permission is still unavailable after checking existing state, ")
|
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. ")
|
.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) {
|
private Settlement classify(ClaimedRun run,SegmentOutcome outcome,GoalEntity fresh,LocalDateTime now) {
|
||||||
int failures=run.candidate().failures();
|
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);
|
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)) {
|
if(fresh!=null && fresh.getStatus()==GoalStatus.PAUSED && goals.isBudgetExhausted(fresh)) {
|
||||||
return new Settlement("succeeded","budget_limited",now,0,goals.exhaustionReason(fresh),null);
|
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.
|
`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.
|
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 引用;事务回滚不发布完成事件或完成记忆。
|
`GET /checks` 读取每条要求的当前资格。要求修改、Goal 定义修改、槽出现新版本、版本过期或正文完整性失败都会使旧绑定失效;需要按当前条件重新检查。每次绑定与 Goal version 更新同事务,失败回滚不留下通过凭据。历史诊断接口的 `acceptanceEligible=false` 保持不变,只有此受管版本检查产生绑定。完成事件保留本次受管绑定的条件修订、产物 ID 和 generation 引用;事务回滚不发布完成事件或完成记忆。
|
||||||
|
|
||||||
这是逐 Goal 显式选择的有限受管 JSON 协议;宽泛执行证据账本的全局 ENFORCE 配置仍遵循原有准入限制。此协议不把任何普通工具成功文本或诊断 MATCH 自动升级为绑定。后端服务已覆盖成功、失效、竞争和回滚;完整浏览器服务闭环、重启和外部数据库验证仍在推进。
|
这是逐 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);
|
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
|
||||||
assertNotNull(effective, "toolsDisabled=true 时绝不能返回 null(那会让全局默认工具又流回来)");
|
assertNotNull(effective, "toolsDisabled=true 时绝不能返回 null(那会让全局默认工具又流回来)");
|
||||||
assertTrue(effective.contains("record_lesson"), "system-level memory 工具必须保留");
|
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_"));
|
boolean hasMcp = effective.stream().anyMatch(n -> n != null && n.startsWith("mcp_"));
|
||||||
assertFalse(hasMcp,
|
assertFalse(hasMcp,
|
||||||
"toolsDisabled=true 时 enabled MCP 工具绝不能自动并入 —— 否则用户的 '禁用所有工具' 意图被违背。"
|
"toolsDisabled=true 时 enabled MCP 工具绝不能自动并入 —— 否则用户的 '禁用所有工具' 意图被违背。"
|
||||||
|
|||||||
@ -231,6 +231,21 @@ class GoalEvaluationNodeContinuationTest {
|
|||||||
assertEquals(Boolean.TRUE, out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN));
|
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 =====
|
// ===== Test fixture =====
|
||||||
|
|
||||||
private static final class 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("difficulty"));
|
||||||
assertTrue(prompt.contains("time"));
|
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));
|
assertTrue(coordinator.settle(second,new SegmentOutcome.Continue("unfinished"),secondStart));
|
||||||
assertEquals(secondStart.plusSeconds(600),continuations.get(1L).nextRunAt());
|
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