diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index 0159c6de..0adb1ad9 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -118,7 +118,14 @@ public class DelegateAgentTool { // long-term memory surface. "remember", "remember_structured", - "forget_structured" + "forget_structured", + // RFC 48 — goal ownership is bound to the parent conversation. + // A child mutating the parent's goal would let sub-agents + // declare the parent's goal "completed" or replace its budget. + "setGoal", + "addGoalCriterion", + "completeGoal", + "getGoalStatus" ); /** Executor for parallel delegation — one JDK 21 virtual thread per child agent. */ diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java new file mode 100644 index 00000000..17d46ee8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java @@ -0,0 +1,215 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.exception.MateClawException; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.service.GoalService; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Built-in tools that let an agent create and manipulate its own + * persistent goal. The agent should reach for these when the user states + * an objective that spans multiple turns — the runtime then tracks + * progress across the entire conversation. + * + *

All four tool names are added to + * {@code DelegateAgentTool.DEFAULT_CHILD_DENIED_TOOLS} so a child agent + * cannot mutate the parent conversation's goal. Goal ownership is bound + * to the parent conversation, period. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GoalManagementTool { + + private final GoalService goalService; + private final GoalProperties properties; + private final ObjectMapper objectMapper; + + @Tool(description = """ + Set a persistent goal for the current conversation. The agent will \ + self-evaluate progress after every reply and surface what is still \ + missing. Use ONLY when the user states an objective that genuinely \ + spans multiple turns (e.g. 'deploy this to production', 'rewrite \ + this module to use async I/O'). Single-question Q&A does not need \ + a goal.""") + public String setGoal( + @ToolParam(description = "Short title under 80 chars; shown in UI hover.") String title, + @ToolParam(description = "Full description of what success looks like.", + required = false) String description, + @ToolParam(description = "Exit criteria the evaluator scores against (e.g. 'tests pass + deployed').", + required = false) String exitCriteria, + @ToolParam(description = "Max evaluation turns before exhaustion. Default 20.", + required = false) Integer turnBudget, + @ToolParam(description = "If true, the agent may auto-followup when progress is incomplete. Default false.", + required = false) Boolean autoFollowup, + @Nullable ToolContext ctx) { + + if (!properties.isEnabled()) { + return errorJson("Goal subsystem is disabled on this server"); + } + if (title == null || title.isBlank()) { + return errorJson("title is required"); + } + + ChatOrigin origin = ChatOrigin.from(ctx); + if (origin == null || origin.conversationId() == null || origin.conversationId().isBlank()) { + return errorJson("setGoal requires a bound conversation context"); + } + if (origin.agentId() == null) { + return errorJson("setGoal requires an agent context"); + } + + GoalCreateRequest req = new GoalCreateRequest(); + req.setConversationId(origin.conversationId()); + req.setAgentId(origin.agentId()); + req.setWorkspaceId(origin.workspaceId() != null ? origin.workspaceId() : 1L); + req.setTitle(title.trim()); + req.setDescription(description != null ? description : title.trim()); + req.setExitCriteria(exitCriteria); + if (turnBudget != null) req.setTurnBudget(turnBudget); + if (autoFollowup != null) req.setAutoFollowupEnabled(autoFollowup); + + String username = origin.requesterId() != null && !origin.requesterId().isBlank() + ? origin.requesterId() : "system"; + try { + GoalEntity created = goalService.create(req, username); + return successJson(Map.of( + "goalId", String.valueOf(created.getId()), + "status", created.getStatus().getValue(), + "turnBudget", created.getTurnBudget(), + "llmCallBudget", created.getLlmCallBudget(), + "autoFollowup", created.getAutoFollowupEnabled())); + } catch (MateClawException e) { + return errorJson(e.getMessage()); + } + } + + @Tool(description = """ + Append a sub-criterion to the active goal without restarting it. \ + Use when the user adds a new requirement mid-task (e.g. 'also make \ + sure it works on Safari'). No-op if no active goal is bound.""") + public String addGoalCriterion( + @ToolParam(description = "Single new criterion sentence.") String criterion, + @Nullable ToolContext ctx) { + + if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled"); + if (criterion == null || criterion.isBlank()) { + return errorJson("criterion must not be empty"); + } + GoalEntity goal = resolveActive(ctx); + if (goal == null) { + return errorJson("No active goal on this conversation"); + } + String username = resolveUsername(ctx); + try { + GoalEntity updated = goalService.appendCriterion(goal.getId(), criterion.trim(), username); + return successJson(Map.of( + "goalId", String.valueOf(updated.getId()), + "exitCriteria", updated.getExitCriteria() == null ? "" : updated.getExitCriteria())); + } catch (MateClawException e) { + return errorJson(e.getMessage()); + } + } + + @Tool(description = """ + Explicitly mark the active goal as completed. Use ONLY when all \ + exit criteria are satisfied (e.g. tests passed, feature deployed, \ + user confirmed). The runtime evaluator will also mark goals \ + completed automatically when score >= 0.95 — prefer that path.""") + public String completeGoal(@Nullable ToolContext ctx) { + if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled"); + GoalEntity goal = resolveActive(ctx); + if (goal == null) { + return errorJson("No active goal on this conversation"); + } + // Synthesize a completion-style evaluation result for the audit trail. + GoalEvaluationResult synthetic = new GoalEvaluationResult( + 1.0, "completed by agent", GoalEvaluationResult.DECISION_COMPLETED, + true, "manual", 0, 0L); + try { + GoalEntity completed = goalService.markCompleted(goal.getId(), synthetic); + return successJson(Map.of( + "goalId", String.valueOf(completed.getId()), + "status", completed.getStatus().getValue())); + } catch (MateClawException e) { + return errorJson(e.getMessage()); + } + } + + @Tool(description = """ + Get the active goal's current status, progress score, and the most \ + recent gap text. Useful when the user asks 'how are we doing?' or \ + before deciding the next sub-step.""") + public String getGoalStatus(@Nullable ToolContext ctx) { + if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled"); + GoalEntity goal = resolveActive(ctx); + if (goal == null) { + return successJson(Map.of("active", false)); + } + Map out = new LinkedHashMap<>(); + out.put("active", true); + out.put("goalId", String.valueOf(goal.getId())); + out.put("title", goal.getTitle()); + out.put("status", goal.getStatus().getValue()); + out.put("turnsUsed", goal.getTurnsUsed()); + out.put("turnBudget", goal.getTurnBudget()); + out.put("agentLlmCallsUsed", goal.getAgentLlmCallsUsed()); + out.put("evalLlmCallsUsed", goal.getEvalLlmCallsUsed()); + out.put("totalLlmCallsUsed", goal.totalLlmCallsUsed()); + out.put("llmCallBudget", goal.getLlmCallBudget()); + out.put("completionScore", goal.getCompletionScore()); + out.put("progressSummary", goal.getProgressSummary()); + out.put("autoFollowupEnabled", goal.getAutoFollowupEnabled()); + return successJson(out); + } + + // ==================== Internals ==================== + + private GoalEntity resolveActive(ToolContext ctx) { + ChatOrigin origin = ChatOrigin.from(ctx); + if (origin == null || origin.conversationId() == null) return null; + return goalService.findActiveByConversation(origin.conversationId()); + } + + private String resolveUsername(ToolContext ctx) { + ChatOrigin origin = ChatOrigin.from(ctx); + if (origin != null && origin.requesterId() != null && !origin.requesterId().isBlank()) { + return origin.requesterId(); + } + return "system"; + } + + private String successJson(Map payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + return "{\"ok\":true}"; + } + } + + private String errorJson(String message) { + try { + return objectMapper.writeValueAsString(Map.of( + "error", true, + "message", message != null ? message : "")); + } catch (JsonProcessingException e) { + return "{\"error\":true}"; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java new file mode 100644 index 00000000..9f6bbe60 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java @@ -0,0 +1,179 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.model.ToolContext; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.exception.MateClawException; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.service.GoalService; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers the four @Tool methods on {@link GoalManagementTool}, especially + * the disable-flag short-circuit + ChatOrigin requirement gates. + */ +@ExtendWith(MockitoExtension.class) +class GoalManagementToolTest { + + @Mock private GoalService goalService; + + private GoalProperties properties; + private GoalManagementTool tool; + + @BeforeEach + void setUp() { + properties = new GoalProperties(); + properties.setEnabled(true); + tool = new GoalManagementTool(goalService, properties, new ObjectMapper()); + } + + private ToolContext ctxWith(String convId, Long agentId, String requester) { + ChatOrigin origin = ChatOrigin.web(convId, requester, 1L, "/tmp") + .withAgent(agentId); + return origin.toToolContext(); + } + + private GoalEntity goal(GoalStatus status) { + GoalEntity g = new GoalEntity(); + g.setId(123L); + g.setConversationId("conv-1"); + g.setAgentId(10L); + g.setWorkspaceId(1L); + g.setTitle("ship the blog"); + g.setStatus(status); + g.setTurnBudget(20); + g.setTurnsUsed(3); + g.setLlmCallBudget(200); + g.setAgentLlmCallsUsed(12); + g.setEvalLlmCallsUsed(2); + g.setAutoFollowupEnabled(false); + return g; + } + + // ==================== setGoal ==================== + + @Test + void setGoal_disabledFlag_returnsError() { + properties.setEnabled(false); + String result = tool.setGoal("title", null, null, null, null, + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("disabled")); + verify(goalService, never()).create(any(), anyString()); + } + + @Test + void setGoal_blankTitle_returnsError() { + String result = tool.setGoal(" ", null, null, null, null, + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("title is required")); + } + + @Test + void setGoal_happyPath_returnsGoalId() { + when(goalService.create(any(GoalCreateRequest.class), eq("alice"))) + .thenReturn(goal(GoalStatus.ACTIVE)); + String result = tool.setGoal("ship the blog", + "deploy to fly.io", + "tests pass + deployed", + 15, true, + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"goalId\":\"123\"")); + assertTrue(result.contains("\"status\":\"active\"")); + } + + @Test + void setGoal_missingConversationContext_returnsError() { + String result = tool.setGoal("title", null, null, null, null, null); + assertTrue(result.contains("requires a bound conversation")); + } + + // ==================== addGoalCriterion ==================== + + @Test + void addCriterion_noActiveGoal_returnsError() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(null); + String result = tool.addGoalCriterion("test on Safari too", + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("No active goal")); + verify(goalService, never()).appendCriterion(any(), anyString(), anyString()); + } + + @Test + void addCriterion_blankInput_returnsError() { + String result = tool.addGoalCriterion(" ", + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("must not be empty")); + } + + @Test + void addCriterion_happyPath_delegatesToService() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(goal(GoalStatus.ACTIVE)); + when(goalService.appendCriterion(eq(123L), eq("test on Safari"), eq("alice"))) + .thenReturn(goal(GoalStatus.ACTIVE)); + String result = tool.addGoalCriterion("test on Safari", + ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"goalId\":\"123\"")); + } + + // ==================== completeGoal ==================== + + @Test + void completeGoal_noActiveGoal_returnsError() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(null); + String result = tool.completeGoal(ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("No active goal")); + verify(goalService, never()).markCompleted(any(), any(GoalEvaluationResult.class)); + } + + @Test + void completeGoal_happyPath_callsMarkCompleted() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(goal(GoalStatus.ACTIVE)); + GoalEntity completed = goal(GoalStatus.COMPLETED); + when(goalService.markCompleted(eq(123L), any(GoalEvaluationResult.class))) + .thenReturn(completed); + String result = tool.completeGoal(ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"status\":\"completed\"")); + } + + // ==================== getGoalStatus ==================== + + @Test + void getGoalStatus_noActive_returnsActiveFalse() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(null); + String result = tool.getGoalStatus(ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"active\":false")); + } + + @Test + void getGoalStatus_active_carriesProgressSummary() { + GoalEntity g = goal(GoalStatus.ACTIVE); + g.setProgressSummary("missing DNS"); + g.setCompletionScore(0.62); + when(goalService.findActiveByConversation("conv-1")).thenReturn(g); + String result = tool.getGoalStatus(ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"goalId\":\"123\"")); + assertTrue(result.contains("\"completionScore\":0.62")); + assertTrue(result.contains("missing DNS")); + // total = agent(12) + eval(2) + assertTrue(result.contains("\"totalLlmCallsUsed\":14")); + } +}