feat(goal): built-in tools for agent-driven goal management

This commit is contained in:
matevip 2026-05-21 14:43:20 +08:00
parent ce74a0ae48
commit 6646e91585
3 changed files with 402 additions and 1 deletions

View File

@ -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. */

View File

@ -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.
*
* <p>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<String, Object> 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<String, Object> 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}";
}
}
}

View File

@ -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"));
}
}