mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(team): team_tasks tool with role gating and team context injection into agent prompts
This commit is contained in:
parent
86e65beafe
commit
8fa10e3769
@ -60,6 +60,7 @@ import vip.mate.tool.guard.service.ToolGuardService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.team.service.TeamContextBuilder;
|
||||
import vip.mate.wiki.service.WikiContextService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
@ -101,6 +102,7 @@ public class AgentGraphBuilder {
|
||||
"${mate.agent.markdown-normalize-enabled:true}")
|
||||
private boolean markdownNormalizeEnabled;
|
||||
private final ConversationService conversationService;
|
||||
private final TeamContextBuilder teamContextBuilder;
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final ModelContextWindowResolver contextWindowResolver;
|
||||
@ -1751,7 +1753,12 @@ public class AgentGraphBuilder {
|
||||
Integer wikiBudgetTokens = memoryBudgetTokens == Integer.MAX_VALUE ? null : memoryBudgetTokens;
|
||||
String wikiContext = wikiContextService.buildWikiContext(entity.getId(), wikiBudgetTokens);
|
||||
|
||||
return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext;
|
||||
// Team context (role-specific board playbook, or a negative notice for
|
||||
// agents outside any team). Baked here so it shares the prompt-cache
|
||||
// prefix; TeamChangedEvent evicts the cached agent on composition changes.
|
||||
String teamContext = teamContextBuilder.buildTeamContext(entity.getId());
|
||||
|
||||
return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext + teamContext;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -21,6 +21,7 @@ import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
|
||||
import vip.mate.memory.lifecycle.TurnContext;
|
||||
import vip.mate.memory.service.MemoryRecallTracker;
|
||||
import vip.mate.team.event.TeamChangedEvent;
|
||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||
|
||||
@ -239,6 +240,18 @@ public class AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached agents whenever their team's composition or settings
|
||||
* change. The team context block is baked into the system prompt at build
|
||||
* time, so membership edits would otherwise stay invisible until restart.
|
||||
*/
|
||||
@EventListener
|
||||
public void onTeamChanged(TeamChangedEvent event) {
|
||||
if (event.agentIds() != null) {
|
||||
event.agentIds().forEach(agentInstances::remove);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 运行时入口 ====================
|
||||
|
||||
public String chat(Long agentId, String message, String conversationId) {
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
package vip.mate.team.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
import vip.mate.team.model.AgentTeamMemberEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Renders the team context block ("TEAM.md") appended to an agent's system
|
||||
* prompt. The lead receives the full orchestration playbook; members receive
|
||||
* execution-focused instructions; agents outside any team receive a one-line
|
||||
* negative notice so the model never probes the team_tasks tool speculatively.
|
||||
*
|
||||
* The block is baked into the cached agent instance; {@code TeamChangedEvent}
|
||||
* evicts affected agents so composition changes surface on the next turn.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TeamContextBuilder {
|
||||
|
||||
static final String NO_TEAM_NOTICE = """
|
||||
|
||||
## Team
|
||||
You are not part of any agent team. Do NOT call the team_tasks tool.
|
||||
""";
|
||||
|
||||
private final TeamService teamService;
|
||||
private final AgentMapper agentMapper;
|
||||
|
||||
/** Build the team context block for the given agent; never returns null. */
|
||||
public String buildTeamContext(Long agentId) {
|
||||
Optional<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId);
|
||||
if (teamOpt.isEmpty()) {
|
||||
return NO_TEAM_NOTICE;
|
||||
}
|
||||
AgentTeamEntity team = teamOpt.get();
|
||||
List<AgentTeamMemberEntity> members = teamService.listMembers(team.getId());
|
||||
boolean isLead = teamService.isLead(team, agentId);
|
||||
|
||||
StringBuilder sb = new StringBuilder(2048);
|
||||
sb.append("\n\n## Team: ").append(team.getName()).append('\n');
|
||||
if (team.getDescription() != null && !team.getDescription().isBlank()) {
|
||||
sb.append(team.getDescription()).append('\n');
|
||||
}
|
||||
sb.append("Your role: ").append(isLead ? "LEAD — you orchestrate this team." : "MEMBER.")
|
||||
.append('\n');
|
||||
|
||||
sb.append("""
|
||||
|
||||
### Members
|
||||
This is the complete and authoritative list of your team. Do NOT use tools to verify it.
|
||||
""");
|
||||
for (AgentTeamMemberEntity member : members) {
|
||||
AgentEntity agent = agentMapper.selectById(member.getAgentId());
|
||||
String name = agent != null && agent.getName() != null ? agent.getName()
|
||||
: String.valueOf(member.getAgentId());
|
||||
sb.append("- **").append(name).append("** (agentId: ").append(member.getAgentId())
|
||||
.append(", ").append(member.getRole()).append(')');
|
||||
if (member.getAgentId().equals(agentId)) {
|
||||
sb.append(" — you");
|
||||
} else if (agent != null && agent.getDescription() != null
|
||||
&& !agent.getDescription().isBlank()) {
|
||||
sb.append(": ").append(agent.getDescription().strip());
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
|
||||
sb.append(isLead ? leadPlaybook() : memberPlaybook());
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String leadPlaybook() {
|
||||
return """
|
||||
|
||||
### Delegation workflow (mandatory)
|
||||
- Delegate work by creating tasks on the team board: `team_tasks(action="create", subject=..., description=..., assigneeAgentId=...)`. Every delegation MUST go through the board — never pretend a teammate did something without a task backing it.
|
||||
- Check the board FIRST: call `team_tasks(action="list")` before creating tasks so you never create duplicates.
|
||||
- Create ALL tasks for the request up front in one batch. Order dependent work with `blockedBy` (ids of prerequisite tasks). Then announce the assignments to the user and STOP — do not keep reasoning while members work.
|
||||
- Delegation is NOT completion. After creating tasks, never say the work is "done" or "finished"; say it has been assigned and results will follow.
|
||||
- Never assign a task to yourself — the lead orchestrates, members execute.
|
||||
- Task sizing: one task = one specific action producing one output. Split a task if it needs two different skills; do not over-split mechanical steps.
|
||||
- If a prerequisite task is already completed, pass its result inside the new task's description instead of blocking on it.
|
||||
|
||||
### When results arrive
|
||||
Member results are delivered to you as system messages in this conversation. Review them, cross-check against the original request, then synthesize ONE coherent reply for the user. Do not forward raw member output unedited.
|
||||
|
||||
### Handling blockers
|
||||
When a member reports a blocker the task auto-fails and you are notified with the reason. Resolve the missing input (provide context, adjust the description), then re-dispatch it with `team_tasks(action="retry", taskId=...)`, or cancel it with `action="cancel"` if it is no longer needed.
|
||||
""";
|
||||
}
|
||||
|
||||
private static String memberPlaybook() {
|
||||
return """
|
||||
|
||||
### Working on assigned tasks
|
||||
- When a task is dispatched to you, focus entirely on executing it. Your final reply becomes the task result and is reported back to the lead automatically.
|
||||
- Report meaningful milestones with `team_tasks(action="progress", taskId=..., percent=..., step=...)`. The taskId is included in the dispatch message.
|
||||
- Leave findings other teammates may need as comments: `team_tasks(action="comment", taskId=..., text=...)`.
|
||||
- If you cannot proceed (missing input, unclear scope, failed dependency), report it with `team_tasks(action="comment", taskId=..., type="blocker", text="what you need")`. This fails the task and notifies the lead — do NOT silently improvise around a blocker.
|
||||
- You may inspect the board with `action="list"` or `action="get"` for context, but do not create or cancel tasks.
|
||||
""";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,320 @@
|
||||
package vip.mate.team.tool;
|
||||
|
||||
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.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
import vip.mate.team.model.TeamTaskCommentEntity;
|
||||
import vip.mate.team.model.TeamTaskCreateCommand;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
import vip.mate.team.model.TeamTaskStatus;
|
||||
import vip.mate.team.service.TeamDispatchService;
|
||||
import vip.mate.team.service.TeamService;
|
||||
import vip.mate.team.service.TeamTaskService;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Shared team task board exposed to the LLM. One multi-action tool (rather
|
||||
* than one tool per action) keeps the schema compact and mirrors how the
|
||||
* model already phrases board operations as an action verb plus fields.
|
||||
*
|
||||
* Role gating: only the lead creates/cancels/retries tasks; members complete,
|
||||
* report progress, and comment; everyone reads. All errors return structured
|
||||
* strings written for LLM self-correction, never exceptions.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TeamTasksTool {
|
||||
|
||||
private final TeamService teamService;
|
||||
private final TeamTaskService taskService;
|
||||
private final TeamDispatchService dispatchService;
|
||||
private final ConversationService conversationService;
|
||||
private final AgentMapper agentMapper;
|
||||
|
||||
@Tool(description = "Operate your team's shared task board. Actions: "
|
||||
+ "'list' all tasks; 'get' one task with comments (taskId); "
|
||||
+ "'create' a task (lead only; subject, description, assigneeAgentId required, "
|
||||
+ "optional blockedBy comma-separated prerequisite task ids, priority, higher first); "
|
||||
+ "'complete' a task with its result summary (taskId, result); "
|
||||
+ "'progress' to report execution progress (taskId, percent 0-100, step); "
|
||||
+ "'comment' to leave a note, or type='blocker' when you are stuck and need the lead "
|
||||
+ "(taskId, text); 'cancel' (lead only; taskId, text as reason); "
|
||||
+ "'retry' a failed/stale task back to pending (lead only; taskId). "
|
||||
+ "Only usable when you belong to an agent team.")
|
||||
public String team_tasks(
|
||||
@ToolParam(description = "One of: list, get, create, complete, progress, comment, cancel, retry")
|
||||
String action,
|
||||
@ToolParam(description = "Task id (string form is fine) — required by every action except list/create", required = false)
|
||||
String taskId,
|
||||
@ToolParam(description = "create: short task title", required = false)
|
||||
String subject,
|
||||
@ToolParam(description = "create: full task instructions; include every input the member needs — members do not see this conversation", required = false)
|
||||
String description,
|
||||
@ToolParam(description = "create: agentId of the member who should execute the task", required = false)
|
||||
String assigneeAgentId,
|
||||
@ToolParam(description = "create: comma-separated ids of tasks that must finish first", required = false)
|
||||
String blockedBy,
|
||||
@ToolParam(description = "create: priority, higher dispatches first (default 0)", required = false)
|
||||
Integer priority,
|
||||
@ToolParam(description = "complete: result summary reported back to the lead", required = false)
|
||||
String result,
|
||||
@ToolParam(description = "progress: completion percent 0-100", required = false)
|
||||
Integer percent,
|
||||
@ToolParam(description = "progress: one-line description of the current step", required = false)
|
||||
String step,
|
||||
@ToolParam(description = "comment/cancel: comment text or cancellation reason", required = false)
|
||||
String text,
|
||||
@ToolParam(description = "comment: 'note' (default) or 'blocker' to escalate to the lead", required = false)
|
||||
String type,
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||
if (conversationId == null || conversationId.isBlank()) {
|
||||
return "Error: no conversation context bound to this call.";
|
||||
}
|
||||
ConversationEntity conversation = conversationService.findByConversationId(conversationId);
|
||||
if (conversation == null || conversation.getAgentId() == null) {
|
||||
return "Error: cannot resolve the calling agent for this conversation.";
|
||||
}
|
||||
Long agentId = conversation.getAgentId();
|
||||
Optional<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId);
|
||||
if (teamOpt.isEmpty()) {
|
||||
return "Error: you are not part of any agent team; team_tasks is unavailable.";
|
||||
}
|
||||
AgentTeamEntity team = teamOpt.get();
|
||||
boolean isLead = teamService.isLead(team, agentId);
|
||||
|
||||
try {
|
||||
return switch (action == null ? "" : action) {
|
||||
case "list" -> renderBoard(team);
|
||||
case "get" -> renderDetail(team, parseId(taskId, "taskId"));
|
||||
case "create" -> createTask(team, agentId, isLead, subject, description,
|
||||
assigneeAgentId, blockedBy, priority, conversationId);
|
||||
case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result);
|
||||
case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step);
|
||||
case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text);
|
||||
case "cancel" -> cancel(team, isLead, parseId(taskId, "taskId"), text);
|
||||
case "retry" -> retry(team, isLead, parseId(taskId, "taskId"));
|
||||
default -> "Error: unknown action '" + action
|
||||
+ "'. Use one of: list, get, create, complete, progress, comment, cancel, retry.";
|
||||
};
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
return "Error: " + e.getMessage();
|
||||
} catch (Exception e) {
|
||||
log.warn("team_tasks {} failed for team={} agent={}: {}",
|
||||
action, team.getId(), agentId, e.getMessage());
|
||||
return "Error: team_tasks failed — " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== actions ====================
|
||||
|
||||
private String createTask(AgentTeamEntity team, Long agentId, boolean isLead,
|
||||
String subject, String description, String assigneeAgentId,
|
||||
String blockedBy, Integer priority, String conversationId) {
|
||||
if (!isLead) {
|
||||
return "Error: only the team lead can create tasks. Report blockers or ask the "
|
||||
+ "lead via a comment on your current task instead.";
|
||||
}
|
||||
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
|
||||
.teamId(team.getId())
|
||||
.subject(subject)
|
||||
.description(description)
|
||||
.assigneeAgentId(parseId(assigneeAgentId, "assigneeAgentId"))
|
||||
.createdByAgentId(agentId)
|
||||
.priority(priority)
|
||||
.blockedBy(parseIdList(blockedBy))
|
||||
.leadConversationId(conversationId)
|
||||
.build());
|
||||
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
|
||||
dispatchService.requestDispatch(team.getId());
|
||||
}
|
||||
return "✓ Created task #" + task.getTaskNumber() + " (id: " + task.getId()
|
||||
+ ") \"" + task.getSubject() + "\" assigned to " + agentName(task.getAssigneeAgentId())
|
||||
+ ". Status: " + task.getStatus()
|
||||
+ (TeamTaskStatus.BLOCKED.equals(task.getStatus())
|
||||
? " (starts automatically once its prerequisites finish)." : ".")
|
||||
+ " Members are dispatched automatically — do not wait in this turn.";
|
||||
}
|
||||
|
||||
private String completeTask(AgentTeamEntity team, Long agentId, Long taskId, String result) {
|
||||
requireTaskInTeam(team, taskId);
|
||||
if (result == null || result.isBlank()) {
|
||||
return "Error: result is required — summarize what was produced.";
|
||||
}
|
||||
List<Long> released = taskService.completeTask(taskId, agentId, result);
|
||||
if (!released.isEmpty()) {
|
||||
dispatchService.requestDispatch(team.getId());
|
||||
}
|
||||
TeamTaskEntity task = taskService.getTask(taskId);
|
||||
StringBuilder sb = new StringBuilder("✓ Task #" + task.getTaskNumber() + " "
|
||||
+ task.getStatus() + ".");
|
||||
if (TeamTaskStatus.IN_REVIEW.equals(task.getStatus())) {
|
||||
sb.append(" It awaits human approval before counting as done.");
|
||||
}
|
||||
if (!released.isEmpty()) {
|
||||
sb.append(" Released ").append(released.size()).append(" dependent task(s).");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String progress(AgentTeamEntity team, Long agentId, Long taskId,
|
||||
Integer percent, String step) {
|
||||
requireTaskInTeam(team, taskId);
|
||||
if (percent != null && (percent < 0 || percent > 100)) {
|
||||
return "Error: percent must be between 0 and 100.";
|
||||
}
|
||||
boolean ok = taskService.updateProgress(taskId, agentId, percent, step);
|
||||
return ok ? "✓ Progress recorded."
|
||||
: "Error: task is not in progress under your ownership; progress not recorded.";
|
||||
}
|
||||
|
||||
private String comment(AgentTeamEntity team, Long agentId, Long taskId,
|
||||
String type, String text) {
|
||||
requireTaskInTeam(team, taskId);
|
||||
if (text == null || text.isBlank()) {
|
||||
return "Error: text is required for a comment.";
|
||||
}
|
||||
boolean escalated = taskService.addComment(taskId, TeamTaskService.AUTHOR_AGENT,
|
||||
String.valueOf(agentId), type, text);
|
||||
return escalated
|
||||
? "✓ Blocker recorded. The task is now failed and the lead has been notified — stop working on it."
|
||||
: "✓ Comment added.";
|
||||
}
|
||||
|
||||
private String cancel(AgentTeamEntity team, boolean isLead, Long taskId, String reason) {
|
||||
if (!isLead) {
|
||||
return "Error: only the team lead can cancel tasks.";
|
||||
}
|
||||
requireTaskInTeam(team, taskId);
|
||||
taskService.cancelTask(taskId, reason);
|
||||
return "✓ Task cancelled.";
|
||||
}
|
||||
|
||||
private String retry(AgentTeamEntity team, boolean isLead, Long taskId) {
|
||||
if (!isLead) {
|
||||
return "Error: only the team lead can retry tasks.";
|
||||
}
|
||||
requireTaskInTeam(team, taskId);
|
||||
if (!taskService.retryTask(taskId)) {
|
||||
return "Error: only failed or stale tasks can be retried.";
|
||||
}
|
||||
dispatchService.requestDispatch(team.getId());
|
||||
return "✓ Task reset to pending; it will be re-dispatched.";
|
||||
}
|
||||
|
||||
// ==================== rendering ====================
|
||||
|
||||
private String renderBoard(AgentTeamEntity team) {
|
||||
List<TeamTaskEntity> tasks = taskService.listTasks(team.getId(), null);
|
||||
if (tasks.isEmpty()) {
|
||||
return "The task board is empty.";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder("Task board for team \"")
|
||||
.append(team.getName()).append("\" (").append(tasks.size()).append(" tasks):\n");
|
||||
for (TeamTaskEntity task : tasks) {
|
||||
sb.append("- #").append(task.getTaskNumber())
|
||||
.append(" [").append(task.getStatus()).append("] ")
|
||||
.append(task.getSubject())
|
||||
.append(" (id: ").append(task.getId())
|
||||
.append(", assignee: ").append(agentName(task.getAssigneeAgentId()));
|
||||
if (task.getProgressPercent() != null
|
||||
&& TeamTaskStatus.IN_PROGRESS.equals(task.getStatus())) {
|
||||
sb.append(", ").append(task.getProgressPercent()).append('%');
|
||||
}
|
||||
sb.append(")\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String renderDetail(AgentTeamEntity team, Long taskId) {
|
||||
TeamTaskEntity task = requireTaskInTeam(team, taskId);
|
||||
StringBuilder sb = new StringBuilder(512);
|
||||
sb.append("Task #").append(task.getTaskNumber())
|
||||
.append(" (id: ").append(task.getId()).append(")\n")
|
||||
.append("Subject: ").append(task.getSubject()).append('\n')
|
||||
.append("Status: ").append(task.getStatus()).append('\n')
|
||||
.append("Assignee: ").append(agentName(task.getAssigneeAgentId())).append('\n');
|
||||
if (task.getDescription() != null && !task.getDescription().isBlank()) {
|
||||
sb.append("Description: ").append(task.getDescription()).append('\n');
|
||||
}
|
||||
if (task.getProgressStep() != null) {
|
||||
sb.append("Progress: ").append(task.getProgressPercent() == null ? "?"
|
||||
: task.getProgressPercent()).append("% — ").append(task.getProgressStep()).append('\n');
|
||||
}
|
||||
if (task.getResult() != null && !task.getResult().isBlank()) {
|
||||
sb.append("Result: ").append(task.getResult()).append('\n');
|
||||
}
|
||||
if (task.getReason() != null && !task.getReason().isBlank()) {
|
||||
sb.append("Reason: ").append(task.getReason()).append('\n');
|
||||
}
|
||||
List<TeamTaskCommentEntity> comments = taskService.listComments(taskId);
|
||||
if (!comments.isEmpty()) {
|
||||
sb.append("Comments:\n");
|
||||
for (TeamTaskCommentEntity comment : comments) {
|
||||
sb.append("- [").append(comment.getCommentType()).append("] ")
|
||||
.append(comment.getAuthorType()).append(' ').append(comment.getAuthorId())
|
||||
.append(": ").append(comment.getContent()).append('\n');
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// ==================== helpers ====================
|
||||
|
||||
private TeamTaskEntity requireTaskInTeam(AgentTeamEntity team, Long taskId) {
|
||||
TeamTaskEntity task = taskService.getTask(taskId);
|
||||
if (task == null || !task.getTeamId().equals(team.getId())) {
|
||||
throw new IllegalArgumentException("task " + taskId + " not found on this team's board");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private String agentName(Long agentId) {
|
||||
if (agentId == null) {
|
||||
return "-";
|
||||
}
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
return agent != null && agent.getName() != null ? agent.getName() : String.valueOf(agentId);
|
||||
}
|
||||
|
||||
private static Long parseId(String raw, String field) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " is required");
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(raw.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(field + " must be a numeric id, got: " + raw);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Long> parseIdList(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> ids = new ArrayList<>();
|
||||
for (String part : raw.split(",")) {
|
||||
if (!part.isBlank()) {
|
||||
ids.add(parseId(part, "blockedBy entry"));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
-- V173: Register the team_tasks built-in tool (shared team task board).
|
||||
-- (H2 dialect)
|
||||
|
||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板:lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言(type=blocker 时自动失败并升级给 lead)、complete 提交结果;list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
@ -0,0 +1,6 @@
|
||||
-- V173: Register the team_tasks built-in tool (shared team task board).
|
||||
-- (KingbaseES / PostgreSQL dialect)
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板:lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言(type=blocker 时自动失败并升级给 lead)、complete 提交结果;list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
@ -0,0 +1,6 @@
|
||||
-- V173: Register the team_tasks built-in tool (shared team task board).
|
||||
-- (MySQL dialect)
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000904, 'TeamTasksTool', '团队任务板', '团队共享任务板:lead 用 create 建任务并指派成员(支持 blockedBy 依赖与优先级);成员用 progress 汇报进度、comment 留言(type=blocker 时自动失败并升级给 lead)、complete 提交结果;list/get 查看看板。仅团队成员可用。', 'builtin', 'teamTasksTool', '📋', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||
@ -0,0 +1,113 @@
|
||||
package vip.mate.team.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
import vip.mate.team.model.AgentTeamMemberEntity;
|
||||
import vip.mate.team.model.TeamRole;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Pins the role split of the team prompt block: lead gets the delegation
|
||||
* playbook, members get execution instructions, non-team agents get the
|
||||
* negative notice that keeps the model away from team_tasks.
|
||||
*/
|
||||
class TeamContextBuilderTest {
|
||||
|
||||
private static final Long TEAM_ID = 10L;
|
||||
private static final Long LEAD_ID = 1L;
|
||||
private static final Long MEMBER_ID = 2L;
|
||||
|
||||
private TeamService teamService;
|
||||
private AgentMapper agentMapper;
|
||||
private TeamContextBuilder builder;
|
||||
private AgentTeamEntity team;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
teamService = mock(TeamService.class);
|
||||
agentMapper = mock(AgentMapper.class);
|
||||
builder = new TeamContextBuilder(teamService, agentMapper);
|
||||
|
||||
team = new AgentTeamEntity();
|
||||
team.setId(TEAM_ID);
|
||||
team.setName("内容组");
|
||||
team.setDescription("负责内容生产");
|
||||
team.setLeadAgentId(LEAD_ID);
|
||||
|
||||
AgentTeamMemberEntity lead = member(LEAD_ID, TeamRole.LEAD);
|
||||
AgentTeamMemberEntity writer = member(MEMBER_ID, TeamRole.MEMBER);
|
||||
when(teamService.listMembers(TEAM_ID)).thenReturn(List.of(lead, writer));
|
||||
|
||||
AgentEntity leadAgent = agent("主管", "orchestrates");
|
||||
AgentEntity writerAgent = agent("写手", "writes articles");
|
||||
when(agentMapper.selectById(LEAD_ID)).thenReturn(leadAgent);
|
||||
when(agentMapper.selectById(MEMBER_ID)).thenReturn(writerAgent);
|
||||
}
|
||||
|
||||
private static AgentTeamMemberEntity member(Long agentId, String role) {
|
||||
AgentTeamMemberEntity m = new AgentTeamMemberEntity();
|
||||
m.setTeamId(TEAM_ID);
|
||||
m.setAgentId(agentId);
|
||||
m.setRole(role);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static AgentEntity agent(String name, String description) {
|
||||
AgentEntity a = new AgentEntity();
|
||||
a.setName(name);
|
||||
a.setDescription(description);
|
||||
return a;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("agents outside any team get the negative notice only")
|
||||
void noTeamNegativeNotice() {
|
||||
when(teamService.getTeamForAgent(99L)).thenReturn(Optional.empty());
|
||||
String ctx = builder.buildTeamContext(99L);
|
||||
assertTrue(ctx.contains("not part of any agent team"));
|
||||
assertFalse(ctx.contains("Delegation workflow"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the lead gets the orchestration playbook and the member roster")
|
||||
void leadGetsPlaybook() {
|
||||
when(teamService.getTeamForAgent(LEAD_ID)).thenReturn(Optional.of(team));
|
||||
when(teamService.isLead(team, LEAD_ID)).thenReturn(true);
|
||||
|
||||
String ctx = builder.buildTeamContext(LEAD_ID);
|
||||
|
||||
assertTrue(ctx.contains("## Team: 内容组"));
|
||||
assertTrue(ctx.contains("LEAD — you orchestrate"));
|
||||
assertTrue(ctx.contains("Delegation workflow (mandatory)"));
|
||||
assertTrue(ctx.contains("Delegation is NOT completion"));
|
||||
assertTrue(ctx.contains("写手"));
|
||||
assertTrue(ctx.contains("agentId: " + MEMBER_ID));
|
||||
// The lead must not receive member execution instructions.
|
||||
assertFalse(ctx.contains("Working on assigned tasks"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a member gets execution instructions, not the delegation playbook")
|
||||
void memberGetsExecutionRules() {
|
||||
when(teamService.getTeamForAgent(MEMBER_ID)).thenReturn(Optional.of(team));
|
||||
when(teamService.isLead(team, MEMBER_ID)).thenReturn(false);
|
||||
|
||||
String ctx = builder.buildTeamContext(MEMBER_ID);
|
||||
|
||||
assertTrue(ctx.contains("Your role: MEMBER."));
|
||||
assertTrue(ctx.contains("Working on assigned tasks"));
|
||||
assertTrue(ctx.contains("type=\"blocker\""));
|
||||
assertFalse(ctx.contains("Delegation workflow"));
|
||||
// Self is marked in the roster instead of repeating its description.
|
||||
assertTrue(ctx.contains("— you"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,241 @@
|
||||
package vip.mate.team.tool;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
import vip.mate.team.model.TeamTaskCreateCommand;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
import vip.mate.team.model.TeamTaskStatus;
|
||||
import vip.mate.team.service.TeamDispatchService;
|
||||
import vip.mate.team.service.TeamService;
|
||||
import vip.mate.team.service.TeamTaskService;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Pins the tool facade's contracts: caller resolution via the conversation,
|
||||
* team membership gating, lead-only actions, LLM-friendly error strings, and
|
||||
* the pass-through into TeamTaskService.
|
||||
*/
|
||||
class TeamTasksToolTest {
|
||||
|
||||
private static final String CONV = "conv-1";
|
||||
private static final Long TEAM_ID = 10L;
|
||||
private static final Long LEAD_ID = 1L;
|
||||
private static final Long MEMBER_ID = 2L;
|
||||
|
||||
private TeamService teamService;
|
||||
private TeamTaskService taskService;
|
||||
private TeamDispatchService dispatchService;
|
||||
private ConversationService conversationService;
|
||||
private AgentMapper agentMapper;
|
||||
private TeamTasksTool tool;
|
||||
private AgentTeamEntity team;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
teamService = mock(TeamService.class);
|
||||
taskService = mock(TeamTaskService.class);
|
||||
dispatchService = mock(TeamDispatchService.class);
|
||||
conversationService = mock(ConversationService.class);
|
||||
agentMapper = mock(AgentMapper.class);
|
||||
tool = new TeamTasksTool(teamService, taskService, dispatchService,
|
||||
conversationService, agentMapper);
|
||||
|
||||
team = new AgentTeamEntity();
|
||||
team.setId(TEAM_ID);
|
||||
team.setName("研发组");
|
||||
team.setLeadAgentId(LEAD_ID);
|
||||
|
||||
ToolExecutionContext.set(CONV, "admin");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
ToolExecutionContext.clear();
|
||||
}
|
||||
|
||||
private void callerIs(Long agentId) {
|
||||
ConversationEntity conv = new ConversationEntity();
|
||||
conv.setConversationId(CONV);
|
||||
conv.setAgentId(agentId);
|
||||
when(conversationService.findByConversationId(CONV)).thenReturn(conv);
|
||||
when(teamService.getTeamForAgent(agentId)).thenReturn(Optional.of(team));
|
||||
when(teamService.isLead(team, agentId)).thenReturn(agentId.equals(LEAD_ID));
|
||||
}
|
||||
|
||||
private TeamTaskEntity task(Long id, String status) {
|
||||
TeamTaskEntity t = new TeamTaskEntity();
|
||||
t.setId(id);
|
||||
t.setTeamId(TEAM_ID);
|
||||
t.setTaskNumber(3);
|
||||
t.setSubject("collect data");
|
||||
t.setStatus(status);
|
||||
t.setAssigneeAgentId(MEMBER_ID);
|
||||
return t;
|
||||
}
|
||||
|
||||
private String invoke(String action, String taskId) {
|
||||
return tool.team_tasks(action, taskId, null, null, null, null, null,
|
||||
null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
// ==================== context & membership gating ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("no conversation context yields a structured error")
|
||||
void noContextError() {
|
||||
ToolExecutionContext.clear();
|
||||
assertTrue(invoke("list", null).startsWith("Error: no conversation context"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an agent outside any team is refused")
|
||||
void nonTeamAgentRefused() {
|
||||
ConversationEntity conv = new ConversationEntity();
|
||||
conv.setConversationId(CONV);
|
||||
conv.setAgentId(99L);
|
||||
when(conversationService.findByConversationId(CONV)).thenReturn(conv);
|
||||
when(teamService.getTeamForAgent(99L)).thenReturn(Optional.empty());
|
||||
|
||||
assertTrue(invoke("list", null).contains("not part of any agent team"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown action lists the valid ones")
|
||||
void unknownAction() {
|
||||
callerIs(LEAD_ID);
|
||||
assertTrue(invoke("destroy", null).contains("unknown action"));
|
||||
}
|
||||
|
||||
// ==================== role gating ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("member create is refused — only the lead delegates")
|
||||
void memberCannotCreate() {
|
||||
callerIs(MEMBER_ID);
|
||||
String out = tool.team_tasks("create", null, "subj", "desc",
|
||||
String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null);
|
||||
assertTrue(out.contains("only the team lead can create"));
|
||||
verify(taskService, never()).createTask(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("member cancel and retry are refused")
|
||||
void memberCannotCancelOrRetry() {
|
||||
callerIs(MEMBER_ID);
|
||||
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.PENDING));
|
||||
assertTrue(invoke("cancel", "5").contains("only the team lead"));
|
||||
assertTrue(invoke("retry", "5").contains("only the team lead"));
|
||||
verify(taskService, never()).cancelTask(any(), any());
|
||||
verify(taskService, never()).retryTask(any());
|
||||
}
|
||||
|
||||
// ==================== create pass-through ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("lead create parses ids, wires the lead conversation and reports the assignee")
|
||||
void leadCreatePassesThrough() {
|
||||
callerIs(LEAD_ID);
|
||||
TeamTaskEntity created = task(50L, TeamTaskStatus.PENDING);
|
||||
when(taskService.createTask(any())).thenReturn(created);
|
||||
AgentEntity member = new AgentEntity();
|
||||
member.setName("写手");
|
||||
when(agentMapper.selectById(MEMBER_ID)).thenReturn(member);
|
||||
|
||||
String out = tool.team_tasks("create", null, "collect data", "step details",
|
||||
String.valueOf(MEMBER_ID), "11,12", 5, null, null, null, null, null, null);
|
||||
|
||||
assertTrue(out.startsWith("✓ Created task #3"));
|
||||
assertTrue(out.contains("写手"));
|
||||
ArgumentCaptor<TeamTaskCreateCommand> captor =
|
||||
ArgumentCaptor.forClass(TeamTaskCreateCommand.class);
|
||||
verify(taskService).createTask(captor.capture());
|
||||
TeamTaskCreateCommand cmd = captor.getValue();
|
||||
assertEquals(MEMBER_ID, cmd.getAssigneeAgentId());
|
||||
assertEquals(List.of(11L, 12L), cmd.getBlockedBy());
|
||||
assertEquals(LEAD_ID, cmd.getCreatedByAgentId());
|
||||
assertEquals(CONV, cmd.getLeadConversationId());
|
||||
verify(dispatchService).requestDispatch(TEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("creating a blocked task does not trigger a dispatch sweep")
|
||||
void blockedCreateDoesNotDispatch() {
|
||||
callerIs(LEAD_ID);
|
||||
TeamTaskEntity blocked = task(51L, TeamTaskStatus.BLOCKED);
|
||||
when(taskService.createTask(any())).thenReturn(blocked);
|
||||
|
||||
tool.team_tasks("create", null, "later step", null,
|
||||
String.valueOf(MEMBER_ID), "50", null, null, null, null, null, null, null);
|
||||
|
||||
verify(dispatchService, never()).requestDispatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("service validation errors surface as Error: strings, not exceptions")
|
||||
void serviceErrorsBecomeStrings() {
|
||||
callerIs(LEAD_ID);
|
||||
when(taskService.createTask(any()))
|
||||
.thenThrow(new IllegalArgumentException("assignee is required"));
|
||||
String out = tool.team_tasks("create", null, "s", null,
|
||||
String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null);
|
||||
assertEquals("Error: assignee is required", out);
|
||||
}
|
||||
|
||||
// ==================== member execution actions ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("complete requires a result and reports released dependents")
|
||||
void completeReportsRelease() {
|
||||
callerIs(MEMBER_ID);
|
||||
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.COMPLETED));
|
||||
when(taskService.completeTask(5L, MEMBER_ID, "done, see report"))
|
||||
.thenReturn(List.of(6L));
|
||||
|
||||
assertTrue(invoke("complete", "5").startsWith("Error: result is required"));
|
||||
|
||||
String ok = tool.team_tasks("complete", "5", null, null, null, null, null,
|
||||
"done, see report", null, null, null, null, null);
|
||||
assertTrue(ok.contains("Released 1 dependent task(s)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a blocker comment tells the member to stop working")
|
||||
void blockerCommentStops() {
|
||||
callerIs(MEMBER_ID);
|
||||
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
||||
when(taskService.addComment(eq(5L), eq(TeamTaskService.AUTHOR_AGENT),
|
||||
anyString(), eq("blocker"), anyString())).thenReturn(true);
|
||||
|
||||
String out = tool.team_tasks("comment", "5", null, null, null, null, null,
|
||||
null, null, null, "missing credentials", "blocker", null);
|
||||
assertTrue(out.contains("stop working"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a task from another team is invisible")
|
||||
void foreignTaskRejected() {
|
||||
callerIs(MEMBER_ID);
|
||||
TeamTaskEntity foreign = task(5L, TeamTaskStatus.PENDING);
|
||||
foreign.setTeamId(999L);
|
||||
when(taskService.getTask(5L)).thenReturn(foreign);
|
||||
|
||||
assertTrue(invoke("get", "5").contains("not found on this team's board"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user