mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(goal): built-in tools for agent-driven goal management
This commit is contained in:
parent
ce74a0ae48
commit
6646e91585
@ -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. */
|
||||
|
||||
@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user