mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 12:27:53 +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.
|
// long-term memory surface.
|
||||||
"remember",
|
"remember",
|
||||||
"remember_structured",
|
"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. */
|
/** 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